Add images on EditListingForm (form does not yet handle image state)

This commit is contained in:
Vesa Luusua 2017-03-10 00:49:41 +02:00
parent 5d0e32bcf9
commit 0238dd38f8
12 changed files with 371 additions and 65 deletions

View file

@ -0,0 +1,48 @@
/**
* Promised component makes it easier to render content that
* depends on resolution of a Promise.
*
* How to use:
* <Promised promise={givenPromise} onSuccess={v => <div>{v}</div>} />
*/
import { Component, PropTypes } from 'react';
class Promised extends Component {
constructor(props) {
super(props);
// success value is string to be more useful when rendering texts.
this.state = {
value: '',
error: null,
};
}
componentDidMount() {
this.props.promise
.then(value => {
this.setState({ value });
})
.catch(error => {
this.setState({ error });
});
}
render() {
const { onError, onSuccess } = this.props;
return this.state.error ? onError(this.state.error) : onSuccess(this.state.value);
}
}
Promised.defaultProps = { onError: e => e };
const { func, object } = PropTypes;
Promised.propTypes = {
promise: object.isRequired,
onSuccess: func.isRequired,
onError: func,
};
export default Promised;

View file

@ -11,6 +11,7 @@ import NamedRedirect from './NamedRedirect/NamedRedirect';
import OrderDetailsPanel from './OrderDetailsPanel/OrderDetailsPanel';
import OrderDiscussionPanel from './OrderDiscussionPanel/OrderDiscussionPanel';
import PageLayout from './PageLayout/PageLayout';
import Promised from './Promised/Promised';
import RoutesProvider from './RoutesProvider/RoutesProvider';
import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel';
@ -28,6 +29,7 @@ export {
OrderDetailsPanel,
OrderDiscussionPanel,
PageLayout,
Promised,
RoutesProvider,
SearchResultsPanel,
};

View file

@ -1,3 +1,61 @@
.error {
color: red;
}
.imagesContainer {
width: 100%;
min-height: 150px;
}
.addImageWrapper {
float: left;
width: 110px;
height: 110px;
margin: 5px;
&::after {
content: ".";
visibility: hidden;
display: block;
height: 0;
clear: both;
}
}
.addImage {
width: 110px;
height: 110px;
background-color: lightgrey;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin-top: 0;
}
.thumbnail {
float: left;
position: relative;
width: 110px;
height: 110px;
margin: 5px;
overflow: hidden;
}
.thumbnailImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnailLoading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(211, 211, 211, 0.8);
display: flex;
justify-content: center;
align-items: center;
}

View file

@ -4,8 +4,12 @@ import EditListingForm from './EditListingForm';
export const Empty = {
component: EditListingForm,
props: {
images: [],
onImageUpload(values) {
console.log(`onImageUpload with id (${values.id}) and file name (${values.file.name})`);
},
onSubmit(values) {
console.log('submit new password form values:', values);
console.log('Submit EditListingForm with (unformatted) values:', values);
},
},
};

View file

@ -2,10 +2,23 @@ import React, { Component, PropTypes } from 'react';
import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form';
import { intlShape, injectIntl } from 'react-intl';
import { maxLength, required } from '../../util/validators';
import { Promised } from '../../components';
import css from './EditListingForm.css';
const MAX_LENGTH = 60;
const ACCEPT_IMAGES = 'image/*';
const TITLE_MAX_LENGTH = 60;
// readImage returns a promise which is resolved
// when FileReader has loaded given file as dataURL
const readImage = file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = e => reject(new Error(`Error reading ${file.name}: ${e.target.result}`));
reader.readAsDataURL(file);
});
// Custom inputs with validator messages
const RenderField = ({ input, label, type, meta: { touched, error } }) => {
const component = type === 'textarea'
? <textarea {...input} placeholder={label} />
@ -18,7 +31,7 @@ const RenderField = ({ input, label, type, meta: { touched, error } }) => {
{touched && (error && <span className={css.error}>{error}</span>)}
</div>
</div>
)
);
};
const { any, bool, shape, string } = PropTypes;
@ -33,11 +46,53 @@ RenderField.propTypes = {
}).isRequired,
};
// Add image wrapper. Label is the only visible element, file input is hidden.
const RenderAddImage = props => {
const { accept, input: {name, onChange}, label, type, meta: { touched, warning } } = props;
const inputProps = { accept, id: name, name, onChange, type };
return (
<div className={css.addImageWrapper}>
<input
{...inputProps}
style={{ display: 'none' }}
/>
<div>
<label htmlFor={name} className={css.addImage}>{label}</label>
{touched && (warning && <span className={css.warning}>{warning}</span>)}
</div>
</div>
);
};
RenderAddImage.propTypes = {
accept: string.isRequired,
input: any.isRequired,
label: string.isRequired,
type: string.isRequired,
meta: shape({
touched: bool,
warning: string,
}).isRequired,
};
class EditListingForm extends Component {
constructor(props) {
super(props);
this.handleInitialize = this.handleInitialize.bind(this);
this.onImageUploadHandler = this.onImageUploadHandler.bind(this);
}
componentDidMount() {
this.handleInitialize();
}
onImageUploadHandler(event) {
const file = event.target.files[0];
if (file) {
this.props.onImageUpload({ id: `${file.name}_${Date.now()}`, file });
}
}
handleInitialize() {
const { initData = {}, initialize } = this.props;
initialize(initData);
@ -47,6 +102,7 @@ class EditListingForm extends Component {
const {
disabled,
handleSubmit,
images,
intl,
pristine,
saveActionMsg = 'Save listing',
@ -54,9 +110,9 @@ class EditListingForm extends Component {
} = this.props;
const requiredStr = intl.formatMessage({ id: 'EditListingForm.required' });
const maxLengthStr = intl.formatMessage({ id: 'EditListingForm.maxLength' }, {
maxLength: MAX_LENGTH,
maxLength: TITLE_MAX_LENGTH,
});
const maxLength60 = maxLength(maxLengthStr, MAX_LENGTH);
const maxLength60 = maxLength(maxLengthStr, TITLE_MAX_LENGTH);
return (
<form onSubmit={handleSubmit}>
@ -68,6 +124,38 @@ class EditListingForm extends Component {
validate={[required(requiredStr), maxLength60]}
/>
<h3>Images</h3>
<div className={css.imagesContainer}>
{images.map(i => {
// While image is uploading we show overlay on top of thumbnail
const uploadingOverlay = !i.imageId
? <div className={css.thumbnailLoading}>Uploading</div>
: null;
return (
<Promised
key={i.id}
promise={readImage(i.file)}
onSuccess={dataURL => {
return (
<div className={css.thumbnail}>
<img src={dataURL} alt={encodeURIComponent(i.file.name)} className={css.thumbnailImage} />
{uploadingOverlay}
</div>
);
}}
/>
);
})}
<Field
accept={ACCEPT_IMAGES}
component={RenderAddImage}
label="+ Add image"
name="addImage"
onChange={this.onImageUploadHandler}
type="file"
/>
</div>
<Field
name="description"
label="Description"

View file

@ -4,7 +4,7 @@ import EditListingForm from './EditListingForm';
describe('EditListingForm', () => {
it('matches snapshot', () => {
const tree = renderDeep(<EditListingForm />);
const tree = renderDeep(<EditListingForm images={[]} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -19,6 +19,33 @@ exports[`EditListingForm matches snapshot 1`] = `
value="" />
</div>
</div>
<h3>
Images
</h3>
<div
className={undefined}>
<div
className={undefined}>
<input
accept="image/*"
id="addImage"
name="addImage"
onChange={[Function]}
style={
Object {
"display": "none",
}
}
type="file" />
<div>
<label
className={undefined}
htmlFor="addImage">
+ Add image
</label>
</div>
</div>
</div>
<div>
<label
htmlFor="description">

View file

@ -17,6 +17,10 @@ export const SHOW_LISTINGS_REQUEST = 'app/EditListingPage/SHOW_LISTINGS_REQUEST'
export const SHOW_LISTINGS_SUCCESS = 'app/EditListingPage/SHOW_LISTINGS_SUCCESS';
export const SHOW_LISTINGS_ERROR = 'app/EditListingPage/SHOW_LISTINGS_ERROR';
export const UPLOAD_IMAGE_REQUEST = 'app/EditListingPage/UPLOAD_IMAGE_REQUEST';
export const UPLOAD_IMAGE_SUCCESS = 'app/EditListingPage/UPLOAD_IMAGE_SUCCESS';
export const UPLOAD_IMAGE_ERROR = 'app/EditListingPage/UPLOAD_IMAGE_ERROR';
// ================ Reducer ================ //
const initialState = {
@ -25,6 +29,8 @@ const initialState = {
showListingsError: null,
submittedListingId: null,
redirectToListing: false,
images: {},
imageOrder: [],
};
export default function reducer(state = initialState, action = {}) {
@ -51,6 +57,32 @@ export default function reducer(state = initialState, action = {}) {
console.error(payload);
return { ...state, showListingsError: payload, redirectToListing: false };
case UPLOAD_IMAGE_REQUEST: {
// payload.params: { id: 'tempId', file }
const images = {
...state.images,
[payload.params.id]: { ...payload.params, uploadImageError: false },
};
return { ...state, images, imageOrder: state.imageOrder.concat([payload.params.id]) };
}
case UPLOAD_IMAGE_SUCCESS: {
// payload.params: { id: 'tempId', imageId: 'some-real-id'}
const { id, imageId } = payload;
const file = state.images[id].file;
const images = { ...state.images, [id]: { id, imageId, file, uploadImageError: false } };
return { ...state, images };
}
case UPLOAD_IMAGE_ERROR: {
console.error(payload);
const { id, error } = payload;
const { file } = state.images[id];
const images = {
...state.images,
[id]: { id, file, error, imageId: null, uploadImageError: true },
};
return { ...state, images };
}
default:
return state;
}
@ -74,58 +106,54 @@ export const showListings = requestAction(SHOW_LISTINGS_REQUEST);
export const showListingsSuccess = successAction(SHOW_LISTINGS_SUCCESS);
export const showListingsError = errorAction(SHOW_LISTINGS_ERROR);
// SDK method: listings.uploadImage
export const uploadImage = requestAction(UPLOAD_IMAGE_REQUEST);
export const uploadImageSuccess = successAction(UPLOAD_IMAGE_SUCCESS);
export const uploadImageError = errorAction(UPLOAD_IMAGE_ERROR);
// ================ Thunk ================ //
const endpoints = {
[CREATE_LISTING_REQUEST]: {
method: sdk => sdk.listings.create,
success: createListingSuccess,
error: createListingError,
},
[SHOW_LISTINGS_REQUEST]: {
method: sdk => sdk.listings.show,
success: showListingsSuccess,
error: showListingsError,
},
};
function callEndpoint(sdkMethod, dispatchSuccess, dispatchError, action) {
const params = action.payload.params;
return sdkMethod(params).then(resp => dispatchSuccess(resp)).catch(e => dispatchError(e));
export function requestShowListing(actionPayload) {
return (dispatch, getState, sdk) => {
dispatch(showListings(actionPayload));
return sdk.listings
.show(actionPayload)
.then(response => {
// EditListingPage fetches new listing data, which also needs to be added to global data
dispatch(globalShowListingsSuccess(response));
// In case of success, we'll clear state.EditListingPage (user will be redirected away)
dispatch(showListingsSuccess(response));
return response;
})
.catch(e => dispatch(showListingsError(e)));
};
}
export function requestCreateListing(actionPayload) {
return (dispatch, getState, sdk) => {
// Create request action
const action = createListing(actionPayload);
// Notify store that we are sending a request
dispatch(action);
// Select SDK endpoint and call it
const { method, success, error } = endpoints[action.type];
return callEndpoint(
method(sdk),
response => dispatch(success(response)),
e => dispatch(error(e)),
action,
);
dispatch(createListing(actionPayload));
return sdk.listings
.create(actionPayload)
.then(response => {
const id = response.data.data.id.uuid;
// Modify store to understand that we have created listing and can redirect away
dispatch(createListingSuccess(response));
// Fetch listing data so that redirection is smooth
dispatch(requestShowListing({ id, include: ['author', 'images'] }));
return response;
})
.catch(e => dispatch(createListingError(e)));
};
}
export function requestShowListing(actionPayload) {
// Images return imageId which we need to map with previously generated temporary id
export function requestImageUpload(actionPayload) {
return (dispatch, getState, sdk) => {
const action = showListings(actionPayload);
dispatch(action);
const { method, success, error } = endpoints[action.type];
return callEndpoint(
method(sdk),
response => {
// EditListingPage fetches new listing data, which also needs to be added to global data
dispatch(globalShowListingsSuccess(response));
// In case of success, we'll clear state.EditListingPage (user will be redirected away)
dispatch(success(response));
},
e => dispatch(error(e)),
action,
);
const id = actionPayload.id;
dispatch(uploadImage(actionPayload));
return sdk.listings
.uploadImage({ image: actionPayload.file })
.then(resp => dispatch(uploadImageSuccess({ data: { id, imageId: resp.data.data.id } })))
.catch(e => dispatch(uploadImageError({ data: { id, error: e } })));
};
}

View file

@ -6,29 +6,26 @@ import { NamedRedirect, PageLayout } from '../../components';
import { EditListingForm } from '../../containers';
import { getListingsById } from '../../ducks/sdk.duck';
import { createSlug } from '../../util/urlHelpers';
import { requestCreateListing, requestShowListing } from './EditListingPage.duck';
import {
requestCreateListing,
requestShowListing,
requestImageUpload,
} from './EditListingPage.duck';
const formatRequestData = values => {
const { title, description } = values;
const { description, images, title } = values;
return {
title,
description,
address: 'Bulevardi 14, 00200 Helsinki, Finland',
description,
geolocation: {
lat: 40.6,
lng: 73.9,
},
images,
title,
};
};
const onSubmit = (submitListing, onLoadListing) => {
return values =>
submitListing(values).then(resp => onLoadListing(resp.payload.data.id.uuid)).catch(e => {
// eslint-disable-next-line no-console
console.error(e);
});
};
// N.B. All the presentational content needs to be extracted to their own components
export class EditListingPageComponent extends Component {
componentDidMount() {
@ -37,10 +34,19 @@ export class EditListingPageComponent extends Component {
}
}
onSubmit(submitListing) {
const { page } = this.props;
const images = page.imageOrder.map(i => page.images[i].imageId);
// When form is submitted we include images in correct order along with submitted values
// TODO figure out a way to attach images to form (e.g. into some hidden input)
return values => submitListing({ ...values, images });
}
render() {
const { data, intl, onCreateListing, onLoadListing, page, params, type } = this.props;
const { data, intl, onCreateListing, onImageUpload, page, params, type } = this.props;
const isNew = type === 'new';
const id = page.submittedListingId || new types.UUID(params.id);
const id = page.submittedListingId || (params && new types.UUID(params.id));
const listingsById = getListingsById(data, [id]);
const currentListing = listingsById.length > 0 ? listingsById[0] : null;
@ -48,9 +54,12 @@ export class EditListingPageComponent extends Component {
const showForm = isNew || currentListing;
if (shouldRedirect) {
// If page has already listingId (after submit) and current listings exist
// redirect to listing page
const slug = currentListing ? createSlug(currentListing.attributes.title) : null;
return <NamedRedirect name="ListingPage" params={{ id: id.uuid, slug }} />;
} else if (showForm) {
// Show form if user is posting a new listing or editing existing one
const saveActionMsg = page.redirectToListing ? 'Redirecting to listing' : 'Create listing';
const disableForm = page.redirectToListing && !page.showListingsError;
@ -62,17 +71,24 @@ export class EditListingPageComponent extends Component {
}
: {};
// Images are passed to EditListingForm so that it can generate thumbnails out of them
const images = page.imageOrder.map(i => page.images[i]);
return (
<PageLayout title={'Edit listing'}>
<EditListingForm
onSubmit={onSubmit(onCreateListing, onLoadListing)}
saveActionMsg={saveActionMsg}
disabled={disableForm}
images={images}
initData={initData}
onImageUpload={onImageUpload}
onSubmit={this.onSubmit(onCreateListing)}
saveActionMsg={saveActionMsg}
/>
</PageLayout>
);
} else {
// If user has come to this page through a direct linkto edit existing listing,
// we need to load it first.
const loadingPageMsg = {
id: 'ListingPage.loadingListingData',
};
@ -92,6 +108,7 @@ const { func, object, shape, string } = PropTypes;
EditListingPageComponent.propTypes = {
data: object.isRequired,
intl: intlShape.isRequired,
onImageUpload: func.isRequired,
onLoadListing: func.isRequired,
onCreateListing: func.isRequired,
page: object.isRequired,
@ -111,6 +128,7 @@ const mapDispatchToProps = dispatch => {
return {
onLoadListing: id => dispatch(requestShowListing({ id, include: ['author', 'images'] })),
onCreateListing: values => dispatch(requestCreateListing(formatRequestData(values))),
onImageUpload: data => dispatch(requestImageUpload(data)),
};
};

View file

@ -0,0 +1,21 @@
import React from 'react';
import { fakeIntl, renderShallow } from '../../util/test-helpers';
import { EditListingPageComponent } from './EditListingPage';
describe('EditListingPageComponent', () => {
it('matches snapshot', () => {
const tree = renderShallow(
<EditListingPageComponent
data={{ entities: {} }}
images={[]}
intl={fakeIntl}
onCreateListing={v => v}
onLoadListing={v => v}
onImageUpload={v => v}
page={{ imageOrder: [], images: {} }}
type="new"
/>,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,11 @@
exports[`EditListingPageComponent matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="Edit listing">
<ReduxForm
images={Array []}
initData={Object {}}
onImageUpload={[Function]}
onSubmit={[Function]}
saveActionMsg="Create listing" />
</Connect(withRouter(PageLayout))>
`;

View file

@ -272,6 +272,7 @@ describe('data utils', () => {
listing2,
]);
});
it('denormalises multiple relationships when relationship data is empty', () => {
const user1 = createUser('user1');
const listing1 = createListing('listing1');