diff --git a/package.json b/package.json index a40106e5..1ba5c0fd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react-intl": "^2.2.3", "react-redux": "^5.0.3", "react-router-dom": "4.0.0-beta.6", + "react-sortable-hoc": "^0.6.1", "redux": "^3.6.0", "redux-form": "^6.5.0", "redux-saga": "^0.14.3", diff --git a/src/components/AddImages/AddImages.css b/src/components/AddImages/AddImages.css new file mode 100644 index 00000000..0062b883 --- /dev/null +++ b/src/components/AddImages/AddImages.css @@ -0,0 +1,33 @@ +.imagesContainer { + width: 100%; + min-height: 120px; + padding-left: 0; +} + +.thumbnail { + display: block; + 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; +} diff --git a/src/components/AddImages/AddImages.example.css b/src/components/AddImages/AddImages.example.css new file mode 100644 index 00000000..1471a158 --- /dev/null +++ b/src/components/AddImages/AddImages.example.css @@ -0,0 +1,29 @@ +.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; +} + +.addImageInput { + display: none; +} diff --git a/src/components/AddImages/AddImages.example.js b/src/components/AddImages/AddImages.example.js new file mode 100644 index 00000000..68e47571 --- /dev/null +++ b/src/components/AddImages/AddImages.example.js @@ -0,0 +1,81 @@ +import React, { Component } from 'react'; +import { findIndex, uniqueId } from 'lodash'; +import { arrayMove } from 'react-sortable-hoc'; +import AddImages from './AddImages'; +import css from './AddImages.example.css'; + +const getId = () => { + return uniqueId(); +}; + +class AddImagesTest extends Component { + constructor(props, state) { + super(props, state); + this.state = { + images: [], + }; + this.onChange = this.onChange.bind(this); + this.onSortEnd = this.onSortEnd.bind(this); + } + + onChange(event) { + const file = event.target.files[0]; + const fileId = getId(); + const imageData = { file, id: fileId, imageId: null }; + + // Show loading overlay + this.setState({ + images: this.state.images.concat([imageData]), + }); + + // Fake image uploaded state: show image thumbnail + setTimeout( + () => { + + this.setState((prevState) => { + const images = prevState.images; + const imageIndex = findIndex(images, i => i.id === fileId); + const updatedImage = { ...imageData, imageId: fileId }; + const updatedImages = [ + ...images.slice(0, imageIndex), + updatedImage, + ...images.slice(imageIndex + 1), + ]; + return { + images: updatedImages, + }; + }); + }, + 1000); + } + + onSortEnd({ oldIndex, newIndex }) { + const { images } = this.state; + this.setState({ + images: arrayMove(images, oldIndex, newIndex), + }); + } + + render() { + return ( +
+ +
+ + +
+
+
+ ); + } +} + +export const Empty = { + component: AddImagesTest, +}; diff --git a/src/components/AddImages/AddImages.js b/src/components/AddImages/AddImages.js new file mode 100644 index 00000000..6e546b23 --- /dev/null +++ b/src/components/AddImages/AddImages.js @@ -0,0 +1,97 @@ +/** + * Creates a sortable image grid with children added to the end of the created grid. + * + * Example: + * // images = [{ id: 'tempId', imageId: 'realIdFromAPI', file: File }]; + * + * + * + */ +import React, { Component, PropTypes } from 'react'; +import { SortableContainer, SortableElement } from 'react-sortable-hoc'; +import { Promised } from '../../components'; +import css from './AddImages.css'; + +// 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 => { + // eslint-disable-next-line + console.error('Error (', e, `) happened while reading ${file.name}: ${e.target.result}`); + reject(new Error(`Error reading ${file.name}: ${e.target.result}`)); + }; + reader.readAsDataURL(file); +}); + +// Create sortable elments out of given thumbnail file +class Thumbnail extends Component { + constructor(props) { + super(props); + this.state = { + promisedImage: readImage(this.props.file), + }; + } + + render() { + const { file, id, imageId } = this.props; + // While image is uploading we show overlay on top of thumbnail + const uploadingOverlay = !imageId ?
Uploading
: null; + return ( + { + return ( +
  • + {file.name} + {uploadingOverlay} +
  • + ); + }} + renderRejected={() =>
  • Could not read file
  • } + /> + ); + } +}; + +Thumbnail.defaultProps = { imageId: null }; + +const { any, array, func, node, string } = PropTypes; + +Thumbnail.propTypes = { + file: any.isRequired, + id: string.isRequired, + imageId: string, +}; + +const SortableImage = SortableElement(Thumbnail); + +// Create container where there are sortable images and passed children like "Add image" input etc. +const SortableImages = SortableContainer(props => { + const { children, images } = props; + return ( +
      + {images.map((image, index) => )} + {children} +
    + ); +}); + +// Configure sortable container see. https://github.com/clauderic/react-sortable-hoc +// Items can be sorted horizontally, vertically or in a grid. +// axis="xy" means grid like sorting +const AddImages = props => { + return ; +}; + +AddImages.defaultProps = { images: [] }; + +AddImages.propTypes = { + images: array, + children: node.isRequired, + onSortEnd: func.isRequired, +}; + +export default AddImages; diff --git a/src/components/index.js b/src/components/index.js index 940f786c..8a497427 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,3 +1,4 @@ +import AddImages from './AddImages/AddImages'; import BookingInfo from './BookingInfo/BookingInfo'; import Discussion from './Discussion/Discussion'; import FilterPanel from './FilterPanel/FilterPanel'; @@ -16,6 +17,7 @@ import RoutesProvider from './RoutesProvider/RoutesProvider'; import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel'; export { + AddImages, BookingInfo, Discussion, FilterPanel, diff --git a/src/containers/EditListingForm/EditListingForm.css b/src/containers/EditListingForm/EditListingForm.css index c4333d7c..9eb8ee48 100644 --- a/src/containers/EditListingForm/EditListingForm.css +++ b/src/containers/EditListingForm/EditListingForm.css @@ -2,11 +2,6 @@ color: red; } -.imagesContainer { - width: 100%; - min-height: 110px; -} - .addImageWrapper { float: left; width: 110px; @@ -33,31 +28,8 @@ 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; +.addImageInput { + display: none; } .imageRequiredWrapper { diff --git a/src/containers/EditListingForm/EditListingForm.example.js b/src/containers/EditListingForm/EditListingForm.example.js index 91d193de..40c0c361 100644 --- a/src/containers/EditListingForm/EditListingForm.example.js +++ b/src/containers/EditListingForm/EditListingForm.example.js @@ -5,11 +5,14 @@ export const Empty = { component: EditListingForm, props: { images: [], - onImageUpload(values) { + onImageUpload: values => { console.log(`onImageUpload with id (${values.id}) and file name (${values.file.name})`); }, - onSubmit(values) { + onSubmit: values => { console.log('Submit EditListingForm with (unformatted) values:', values); }, + onUpdateImageOrder: imageOrder => { + console.log('onUpdateImageOrder with new imageOrder:', imageOrder); + }, }, }; diff --git a/src/containers/EditListingForm/EditListingForm.js b/src/containers/EditListingForm/EditListingForm.js index 34a5e1af..5dfef8a5 100644 --- a/src/containers/EditListingForm/EditListingForm.js +++ b/src/containers/EditListingForm/EditListingForm.js @@ -2,26 +2,14 @@ import React, { Component, PropTypes } from 'react'; import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form'; import { intlShape, injectIntl } from 'react-intl'; import { isEqual } from 'lodash'; +import { arrayMove } from 'react-sortable-hoc'; import { noEmptyArray, maxLength, required } from '../../util/validators'; -import { Promised } from '../../components'; +import { AddImages } from '../../components'; import css from './EditListingForm.css'; 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 => { - // eslint-disable-next-line - console.error(`Error ${e} happened while reading ${file.name}: ${e.target.result}`); - reject(new Error(`Error reading ${file.name}: ${e.target.result}`)); - }; - reader.readAsDataURL(file); -}); - // Custom inputs with validator messages const RenderField = ({ input, label, type, meta }) => { const { touched, error } = meta; @@ -61,7 +49,7 @@ const RenderAddImage = props => { const inputProps = { accept, id: name, name, onChange, type }; return (
    - +
    ); @@ -83,6 +71,7 @@ class EditListingForm extends Component { super(props); this.handleInitialize = this.handleInitialize.bind(this); this.onImageUploadHandler = this.onImageUploadHandler.bind(this); + this.onSortEnd = this.onSortEnd.bind(this); } componentDidMount() { @@ -102,6 +91,11 @@ class EditListingForm extends Component { } } + onSortEnd({ oldIndex, newIndex }) { + const images = arrayMove(this.props.images, oldIndex, newIndex); + this.props.onUpdateImageOrder(images.map(i => i.id)); + } + handleInitialize() { const { initData = {}, initialize } = this.props; initialize(initData); @@ -135,28 +129,7 @@ class EditListingForm extends Component { />

    Images

    -
    - {images.map(i => { - // While image is uploading we show overlay on top of thumbnail - const uploadingOverlay = !i.imageId - ?
    Uploading
    - : null; - return ( - { - return ( -
    - {i.file.name} - {uploadingOverlay} -
    - ); - }} - renderRejected={() =>
    Could not read file
    } - /> - ); - })} + -
    + { it('matches snapshot', () => { - const tree = renderDeep(); + const tree = renderShallow( v} onSubmit={v => v} onUpdateImageOrder={v => v} />); expect(tree).toMatchSnapshot(); }); }); diff --git a/src/containers/EditListingForm/__snapshots__/EditListingForm.test.js.snap b/src/containers/EditListingForm/__snapshots__/EditListingForm.test.js.snap index 082b5cbf..577ff376 100644 --- a/src/containers/EditListingForm/__snapshots__/EditListingForm.test.js.snap +++ b/src/containers/EditListingForm/__snapshots__/EditListingForm.test.js.snap @@ -1,82 +1,19 @@ exports[`EditListingForm matches snapshot 1`] = ` -
    -
    - -
    - -
    -
    -

    - Images -

    -
    -
    - - -
    -
    - -
    -
    -
    - -
    -