diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d285c24..d4f8d16aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable, unreleased changes to this project will be documented in this file. - Use searches as hooks instead of components - #262 by @dominik-zeglen - Add navigator - #267 by @dominik-zeglen - Fix voucher limit - #271 by @dominik-zeglen +- Allow multiple images to be uploaded - #277 by @dominik-zeglen - Fix dropdown clickable areas - #281 by @dominik-zeglen ## 2.0.0 diff --git a/src/categories/components/CategoryBackground/CategoryBackground.tsx b/src/categories/components/CategoryBackground/CategoryBackground.tsx index ca042996e..8e340e304 100644 --- a/src/categories/components/CategoryBackground/CategoryBackground.tsx +++ b/src/categories/components/CategoryBackground/CategoryBackground.tsx @@ -77,6 +77,7 @@ const CategoryBackground: React.FC = props => { onChange={event => onImageUpload(event.target.files[0])} type="file" ref={anchor} + accept="image/*" /> } @@ -90,7 +91,7 @@ const CategoryBackground: React.FC = props => { ) : image === null ? ( - + onImageUpload(files[0])} /> ) : ( diff --git a/src/collections/components/CollectionImage/CollectionImage.tsx b/src/collections/components/CollectionImage/CollectionImage.tsx index a4cb01d32..6e96904c6 100644 --- a/src/collections/components/CollectionImage/CollectionImage.tsx +++ b/src/collections/components/CollectionImage/CollectionImage.tsx @@ -92,6 +92,7 @@ export const CollectionImage: React.FC = props => { onChange={event => onImageUpload(event.target.files[0])} type="file" ref={anchor} + accept="image/*" /> } @@ -105,7 +106,7 @@ export const CollectionImage: React.FC = props => { ) : image === null ? ( - + onImageUpload(files[0])} /> ) : ( diff --git a/src/components/ImageTile/ImageTile.tsx b/src/components/ImageTile/ImageTile.tsx index 9e71294a5..5d9312f22 100644 --- a/src/components/ImageTile/ImageTile.tsx +++ b/src/components/ImageTile/ImageTile.tsx @@ -1,9 +1,10 @@ -import { makeStyles } from "@material-ui/core/styles"; -import React from "react"; - +import CircularProgress from "@material-ui/core/CircularProgress"; import IconButton from "@material-ui/core/IconButton"; +import { makeStyles } from "@material-ui/core/styles"; import DeleteIcon from "@material-ui/icons/Delete"; import EditIcon from "@material-ui/icons/Edit"; +import classNames from "classnames"; +import React from "react"; const useStyles = makeStyles(theme => ({ image: { @@ -37,6 +38,13 @@ const useStyles = makeStyles(theme => ({ top: 0, width: 148 }, + imageOverlayShow: { + "&$imageOverlay": { + alignItems: "center", + display: "flex", + justifyContent: "center" + } + }, imageOverlayToolbar: { display: "flex", justifyContent: "flex-end" @@ -48,30 +56,39 @@ interface ImageTileProps { alt?: string; url: string; }; + loading?: boolean; onImageDelete?: () => void; onImageEdit?: (event: React.ChangeEvent) => void; } const ImageTile: React.FC = props => { - const { onImageDelete, onImageEdit, image } = props; + const { loading, onImageDelete, onImageEdit, image } = props; const classes = useStyles(props); return (
-
-
- {onImageEdit && ( - - - - )} - {onImageDelete && ( - - - - )} -
+
+ {loading ? ( + + ) : ( +
+ {onImageEdit && ( + + + + )} + {onImageDelete && ( + + + + )} +
+ )}
{image.alt}
diff --git a/src/components/ImageUpload/ImageUpload.tsx b/src/components/ImageUpload/ImageUpload.tsx index 5216a0013..070ecc974 100644 --- a/src/components/ImageUpload/ImageUpload.tsx +++ b/src/components/ImageUpload/ImageUpload.tsx @@ -14,11 +14,11 @@ interface ImageUploadProps { isActiveClassName?: string; iconContainerClassName?: string; iconContainerActiveClassName?: string; - onImageUpload: (file: File) => void; + onImageUpload: (file: FileList) => void; } const useStyles = makeStyles(theme => ({ - containerDragActive: { + backdrop: { background: fade(theme.palette.primary.main, 0.1), color: theme.palette.primary.main }, @@ -57,41 +57,37 @@ const useStyles = makeStyles(theme => ({ export const ImageUpload: React.FC = props => { const { children, - className, disableClick, - isActiveClassName, iconContainerActiveClassName, iconContainerClassName, + isActiveClassName, onImageUpload } = props; const classes = useStyles(props); return ( - onImageUpload(files[0])} - > + {({ isDragActive, getInputProps, getRootProps }) => ( <>
- + ({ imageUpload: { height: "100%", left: 0, + outline: 0, position: "absolute", top: 0, width: "100%" @@ -134,13 +136,14 @@ const SortableImage = SortableElement( interface ImageListContainerProps { className: string; - items: any; + items: ProductDetails_product_images[]; + preview: ProductDetails_product_images[]; onImageDelete: (id: string) => () => void; onImageEdit: (id: string) => () => void; } const ImageListContainer = SortableContainer( - ({ items, onImageDelete, onImageEdit, ...props }) => ( + ({ items, preview, onImageDelete, onImageEdit, ...props }) => (
{items.map((image, index) => ( ( onImageDelete={onImageDelete(image.id)} /> ))} + {preview + .sort((a, b) => (a.sortOrder > b.sortOrder ? 1 : -1)) + .map(image => ( + + ))}
) ); @@ -169,6 +177,32 @@ const ProductImages: React.FC = props => { const classes = useStyles(props); const intl = useIntl(); const upload = React.useRef(null); + const [imagesToUpload, setImagesToUpload] = React.useState< + ProductDetails_product_images[] + >([]); + + const handleImageUpload = createMultiFileUploadHandler(onImageUpload, { + onAfterUpload: () => + setImagesToUpload(prevImagesToUpload => prevImagesToUpload.slice(1)), + onStart: files => { + Array.from(files).forEach((file, fileIndex) => { + const reader = new FileReader(); + reader.onload = event => { + setImagesToUpload(prevImagesToUpload => [ + ...prevImagesToUpload, + { + __typename: "ProductImage", + alt: "", + id: "", + sortOrder: fileIndex, + url: event.target.result as string + } + ]); + }; + reader.readAsDataURL(file); + }); + } + }); return ( @@ -191,9 +225,11 @@ const ProductImages: React.FC = props => { onImageUpload(event.target.files[0])} + onChange={event => handleImageUpload(event.target.files)} + multiple type="file" ref={upload} + accept="image/*" /> } @@ -215,7 +251,7 @@ const ProductImages: React.FC = props => { disableClick={true} iconContainerClassName={classes.imageUploadIcon} iconContainerActiveClassName={classes.imageUploadIconActive} - onImageUpload={onImageUpload} + onImageUpload={handleImageUpload} > {({ isDragActive }) => ( @@ -224,6 +260,7 @@ const ProductImages: React.FC = props => { helperClass="dragged" axis="xy" items={images} + preview={imagesToUpload} onSortEnd={onImageReorder} className={classNames({ [classes.root]: true, @@ -237,7 +274,7 @@ const ProductImages: React.FC = props => { ) : ( - + )}
diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 660fee0a7..0796e9af9 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -16612,6 +16612,7 @@ Ctrl + K"
@@ -93004,13 +93020,14 @@ Ctrl + K" class="makeStyles-imageGridContainer-id" >
@@ -94526,13 +94545,14 @@ Ctrl + K" class="makeStyles-imageGridContainer-id" >
@@ -96307,13 +96329,14 @@ Ctrl + K" class="makeStyles-imageGridContainer-id" >
@@ -99164,8 +99189,10 @@ Ctrl + K"
@@ -99187,6 +99214,7 @@ Ctrl + K" class="" >
@@ -100868,13 +100898,14 @@ Ctrl + K" class="makeStyles-imageGridContainer-id" >
new File([""], "mockFile")); + +describe("Multiple file upload handler", () => { + it("properly handles success", done => { + const cbs = { + onAfterUpload: jest.fn(), + onBeforeUpload: jest.fn(), + onCompleted: jest.fn(files => + expect(files.length).toBe(testFiles.length) + ), + onError: jest.fn(), + onStart: jest.fn() + }; + const handle = createMultiFileUploadHandler(() => { + const promise = new Promise(resolve => { + expect(cbs.onBeforeUpload).toBeCalledTimes( + cbs.onAfterUpload.mock.calls.length + 1 + ); + resolve(); + }); + return promise; + }, cbs); + + handle((testFiles as unknown) as FileList).then(() => { + expect(cbs.onAfterUpload).toBeCalledTimes(testFiles.length); + expect(cbs.onBeforeUpload).toBeCalledTimes(testFiles.length); + expect(cbs.onCompleted).toBeCalledTimes(1); + expect(cbs.onError).toBeCalledTimes(0); + expect(cbs.onStart).toBeCalledTimes(1); + done(); + }); + }); + + it("properly handles error", done => { + const cbs = { + onAfterUpload: jest.fn(), + onBeforeUpload: jest.fn(), + onCompleted: jest.fn(files => + expect(files.length).toBe(testFiles.length) + ), + onError: jest.fn(), + onStart: jest.fn() + }; + const handle = createMultiFileUploadHandler((_, fileIndex) => { + const promise = new Promise((resolve, reject) => { + if (fileIndex === 2) { + reject(); + } else { + resolve(); + } + }); + return promise; + }, cbs); + + handle((testFiles as unknown) as FileList).then(() => { + expect(cbs.onAfterUpload).toBeCalledTimes(testFiles.length - 1); + expect(cbs.onBeforeUpload).toBeCalledTimes(testFiles.length); + expect(cbs.onCompleted).toBeCalledTimes(1); + expect(cbs.onError).toBeCalledTimes(1); + done(); + }); + }); +}); diff --git a/src/utils/handlers/multiFileUploadHandler.ts b/src/utils/handlers/multiFileUploadHandler.ts new file mode 100644 index 000000000..c97b17418 --- /dev/null +++ b/src/utils/handlers/multiFileUploadHandler.ts @@ -0,0 +1,58 @@ +export type CreateMultiFileUploadHandlerCallbacks = Partial<{ + onAfterUpload: (index: number, files: File[]) => void; + onBeforeUpload: (index: number, files: File[]) => void; + onCompleted: (files: File[]) => void; + onError: (index: number, files: File[]) => void; + onStart: (files: File[]) => void; +}>; + +function createMultiFileUploadHandler( + upload: (file: File, fileIndex: number) => Promise, + { + onAfterUpload, + onBeforeUpload, + onCompleted, + onError, + onStart + }: CreateMultiFileUploadHandlerCallbacks +) { + async function uploadImage(files: File[], fileIndex: number): Promise { + if (files.length > fileIndex) { + try { + if (onBeforeUpload) { + onBeforeUpload(fileIndex, files); + } + + await upload(files[fileIndex], fileIndex); + + if (onAfterUpload) { + onAfterUpload(fileIndex, files); + } + } catch (exception) { + console.error( + `Could not upload file #${fileIndex + 1}. Reason: ${exception}` + ); + if (onError) { + onError(fileIndex, files); + } + } finally { + await uploadImage(files, fileIndex + 1); + } + } + } + return async (files: FileList): Promise => { + const fileArray = Array.from(files); + + if (onStart) { + onStart(fileArray); + } + + await uploadImage(fileArray, 0); + + if (onCompleted) { + onCompleted(fileArray); + } + }; +} + +export default createMultiFileUploadHandler;