diff --git a/CHANGELOG.md b/CHANGELOG.md index 072787165..4f663310c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,19 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v0.57.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.56.0...v0.57.0) + +- fix(menubar): remove uuid [`#1659`](https://github.com/isomerpages/isomercms-frontend/pull/1659) +- fix(badge): flickering issue [`#1657`](https://github.com/isomerpages/isomercms-frontend/pull/1657) +- feat(images): bulk upload [`#1654`](https://github.com/isomerpages/isomercms-frontend/pull/1654) +- feat(media): introduce new move media modal [`#1652`](https://github.com/isomerpages/isomercms-frontend/pull/1652) +- IS-685 enhance delete images [`#1631`](https://github.com/isomerpages/isomercms-frontend/pull/1631) +- release(0.56.0): merge to dev [`#1649`](https://github.com/isomerpages/isomercms-frontend/pull/1649) + #### [v0.56.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.55.0...v0.56.0) +> 3 November 2023 + - fix(api): dont ping be every 5 sec if ! whitelist [`#1648`](https://github.com/isomerpages/isomercms-frontend/pull/1648) - fix(protected-route): add missing deps [`#1647`](https://github.com/isomerpages/isomercms-frontend/pull/1647) - release(0.55.0): merge to dev [`#1640`](https://github.com/isomerpages/isomercms-frontend/pull/1640) diff --git a/package-lock.json b/package-lock.json index ea0ed6c1a..aa651b21a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isomercms-frontend", - "version": "0.56.0", + "version": "0.57.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isomercms-frontend", - "version": "0.56.0", + "version": "0.57.0", "hasInstallScript": true, "dependencies": { "@braintree/sanitize-url": "^6.0.1", diff --git a/package.json b/package.json index e6c299a57..9c602488b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isomercms-frontend", - "version": "0.56.0", + "version": "0.57.0", "private": true, "engines": { "node": ">=16.0.0" diff --git a/src/assets/icons/BxErrorSolid.tsx b/src/assets/icons/BxErrorSolid.tsx new file mode 100644 index 000000000..e5be4e228 --- /dev/null +++ b/src/assets/icons/BxErrorSolid.tsx @@ -0,0 +1,19 @@ +export const BxErrorSolid = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + ) +} diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index a488c0e02..3d2822fbb 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -18,3 +18,4 @@ export * from "./BxCopy" export * from "./BxDraggable" export * from "./BxDraggableVertical" export * from "./BxGrayTranslucent" +export * from "./BxErrorSolid" diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 5ca91c3d8..860f23ec8 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -21,7 +21,6 @@ import { AttachmentStylesProvider } from "./AttachmentContext" import { AttachmentDropzone } from "./AttachmentDropzone" import { AttachmentError } from "./AttachmentError" import { AttachmentFileInfo } from "./AttachmentFileInfo" -import { MAX_NUM_UPLOADS } from "./constants" export interface AttachmentProps extends UseFormControlProps { /** @@ -145,7 +144,7 @@ export const Attachment = forwardRef( if (maxSize && file.size > maxSize) { return { code: "file-too-large", - message: `Upload too big (${getReadableFileSize(file.size)})`, + message: `File is too big (${getReadableFileSize(file.size)})`, } } return null @@ -158,7 +157,7 @@ export const Attachment = forwardRef( disabled: inputProps.disabled, validator: fileValidator, onDrop: handleFileDrop, - maxFiles: MAX_NUM_UPLOADS, + multiple: true, }) const mergedRefs = useMergeRefs(rootRef, ref) @@ -213,29 +212,52 @@ export const Attachment = forwardRef( mt="0.5rem" textStyle="body-2" > - Maximum size per image: {readableMaxSize} + Maximum size per file: {readableMaxSize} )} - - Selected images - {`${value.length}/5 images can be uploaded`} - - {value?.map((file) => ( + {value?.length > 0 && ( <> - - + + Files to be uploaded + {`${value.length}/${ + value.length + rejected.length + } files can be uploaded`} + + {value?.map((file) => ( + <> + + + + ))} + {/* NOTE: Add last divider if we have content above */} + {value?.length > 0 && } + + )} + {rejected?.length > 0 && ( + <> + + Unable to upload + {`${rejected.length}/${ + value.length + rejected.length + } files cannot be uploaded`} + + + {rejected?.map((fileRejection) => ( + <> + + + + ))} + {/* NOTE: Add last divider if we have content above */} + {rejected.length > 0 && } + - ))} - {rejected?.map((fileRejection) => ( - - ))} - {/* NOTE: Add last divider if we have content above */} - {(value?.length > 0 || rejected.length > 0) && } + )} ) } diff --git a/src/components/Attachment/AttachmentDropzone.tsx b/src/components/Attachment/AttachmentDropzone.tsx index 0e47eb906..1a06034e5 100644 --- a/src/components/Attachment/AttachmentDropzone.tsx +++ b/src/components/Attachment/AttachmentDropzone.tsx @@ -1,5 +1,5 @@ import { chakra, Icon, Text } from "@chakra-ui/react" -import { Button, Link, BxsCloudUpload } from "@opengovsg/design-system-react" +import { Button, BxsCloudUpload } from "@opengovsg/design-system-react" import { DropzoneInputProps, DropzoneState } from "react-dropzone" import { useAttachmentStyles } from "./AttachmentContext" @@ -31,7 +31,7 @@ export const AttachmentDropzone = ({ or drag and drop here - Images exceeding 5MB and videos are not supported + Only images and PDFs are supported. Files must be smaller than 5MB. )} diff --git a/src/components/Attachment/AttachmentError.tsx b/src/components/Attachment/AttachmentError.tsx index 2c09c513a..462a71cac 100644 --- a/src/components/Attachment/AttachmentError.tsx +++ b/src/components/Attachment/AttachmentError.tsx @@ -75,6 +75,7 @@ export const AttachmentError = forwardRef( ref={ref} tabIndex={0} sx={styles.fileInfoContainer} + w="100%" > File attached: {file.name} with file size of {readableFileSize} @@ -86,13 +87,25 @@ export const AttachmentError = forwardRef( src={previewSrc} /> )} - - - {file.name} + + + + {file.name} + {getErrorMessage(fileRejection)} diff --git a/src/components/Attachment/AttachmentFileInfo.tsx b/src/components/Attachment/AttachmentFileInfo.tsx index 336ec8401..08e5414f1 100644 --- a/src/components/Attachment/AttachmentFileInfo.tsx +++ b/src/components/Attachment/AttachmentFileInfo.tsx @@ -47,6 +47,7 @@ export const AttachmentFileInfo = forwardRef( ref={ref} tabIndex={0} sx={styles.fileInfoContainer} + w="100%" > File attached: {file.name} with file size of {readableFileSize} @@ -58,13 +59,18 @@ export const AttachmentFileInfo = forwardRef( src={previewSrc} /> )} - + - {file.name} + + {file.name} + {readableFileSize} diff --git a/src/components/Attachment/constants.ts b/src/components/Attachment/constants.ts index 525327b01..1e18f8383 100644 --- a/src/components/Attachment/constants.ts +++ b/src/components/Attachment/constants.ts @@ -1,2 +1 @@ export const MAX_UPLOAD_SIZE_MB = 5 -export const MAX_NUM_UPLOADS = 5 diff --git a/src/components/Breadcrumbs/Breadcrumbs.stories.tsx b/src/components/Breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 000000000..aa4a0f80c --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryFn } from "@storybook/react" +import { MemoryRouter, Route } from "react-router-dom" + +import { Breadcrumbs } from "./Breadcrumbs" + +const breakcrumbsMeta = { + title: "Components/Breadcrumbs", + component: Breadcrumbs, + decorators: [ + (Story) => { + return ( + + + + + + ) + }, + ], +} as Meta + +const breadcrumbsTemplate: StoryFn = (props) => { + return +} + +export const Default = breadcrumbsTemplate.bind({}) + +Default.args = { + items: [ + { title: "images", url: "#" }, + { + title: "nested-album", + url: "#", + }, + { + title: "level-3", + url: "#", + }, + ], +} + +export const Truncated = breadcrumbsTemplate.bind({}) + +Truncated.args = { + items: [ + { title: "images", url: "#" }, + { + title: "nested-album", + url: "#", + }, + { + title: "level-3", + url: "#", + }, + { + title: "level-4", + url: "#", + }, + ], +} + +export const NotLinked = breadcrumbsTemplate.bind({}) + +NotLinked.args = { + items: [ + { title: "images", url: "#" }, + { + title: "nested-album", + url: "#", + }, + { + title: "level-3", + url: "#", + }, + { + title: "level-4", + url: "#", + }, + ], + maxBreadcrumbsLength: 4, + spacing: "0rem", + textStyle: "body-2", + isLinked: false, + isLastChildUnderlined: true, +} + +export default breakcrumbsMeta diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 000000000..3dcccb197 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,108 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbLinkProps, + BreadcrumbProps as ChakraBreadcrumbProps, + Icon, + Text, +} from "@chakra-ui/react" +import { BiChevronRight } from "react-icons/bi" +import { Link as RouterLink } from "react-router-dom" + +export interface BreadcrumbItem { + title: string + url?: string +} + +interface BreadcrumbProps { + items: BreadcrumbItem[] + maxBreadcrumbsLength?: number + spacing?: ChakraBreadcrumbProps["spacing"] + textStyle?: BreadcrumbLinkProps["textStyle"] + isLinked?: boolean + isLastChildUnderlined?: boolean +} + +export const Breadcrumbs = ({ + items, + maxBreadcrumbsLength = 3, + spacing = "0.5rem", + textStyle = "caption-2", + isLinked = true, + isLastChildUnderlined = false, +}: BreadcrumbProps): JSX.Element => { + if (items.length === 0) { + return <> + } + + return ( + + } + lineHeight="0.5rem" + > + {items.map(({ title, url }, idx) => { + // Note: Intermediate albums/directories that are beyond the + // maxBreadcrumbsLength are not shown in the breadcrumbs and will be + // replaced with an ellipsis + if ( + items.length > maxBreadcrumbsLength && + idx > 1 && + idx < items.length - (maxBreadcrumbsLength - 1) + ) { + return <> + } + + const isEllipsis = items.length > maxBreadcrumbsLength && idx === 1 + const isLastChild = idx === items.length - 1 + + return ( + + {isLinked ? ( + + {isEllipsis ? "..." : title} + + ) : ( + + {isEllipsis ? "..." : title} + + )} + + ) + })} + + ) +} diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts new file mode 100644 index 000000000..1f6c7f4b2 --- /dev/null +++ b/src/components/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from "./Breadcrumbs" diff --git a/src/components/DeleteMediaModal/DeleteMediaModal.tsx b/src/components/DeleteMediaModal/DeleteMediaModal.tsx index 99293f2d8..f4ffd0642 100644 --- a/src/components/DeleteMediaModal/DeleteMediaModal.tsx +++ b/src/components/DeleteMediaModal/DeleteMediaModal.tsx @@ -19,6 +19,7 @@ import { import { Button, Checkbox, + Infobox, ModalCloseButton, } from "@opengovsg/design-system-react" import { useState } from "react" @@ -49,8 +50,13 @@ export const DeleteMediaModal = ({ const { singularMediaLabel, pluralMediaLabel } = mediaLabels const [isDeleteChecked, setIsDeleteChecked] = useState(false) + const onModalClose = () => { + setIsDeleteChecked(false) + onClose() + } + return ( - + @@ -64,9 +70,16 @@ export const DeleteMediaModal = ({ - Are you sure you want to delete this {singularMediaLabel}? If you - used this {singularMediaLabel} on any page, site visitors may see - a broken {singularMediaLabel}. This cannot be undone. + + Are you sure you want to delete this {singularMediaLabel}? This + cannot be undone. + + + Deleting {pluralMediaLabel} might lead to broken{" "} + {pluralMediaLabel} on pages. This confuses site visitors. We + recommend double-checking that you are not using this{" "} + {singularMediaLabel} on your site. + )} @@ -80,15 +93,10 @@ export const DeleteMediaModal = ({ - + - Are you sure you want to delete {selectedMedia.length}{" "} - {pluralMediaLabel}? -
-
- If you used any of these {pluralMediaLabel} on a page, your - site visitors may see broken {pluralMediaLabel}. This cannot - be undone. + Are you sure you want to delete these {selectedMedia.length}{" "} + {pluralMediaLabel}? This cannot be undone.
@@ -124,6 +132,13 @@ export const DeleteMediaModal = ({ + + + Deleting {pluralMediaLabel} might lead to broken{" "} + {pluralMediaLabel} on pages. This confuses site visitors. We + recommend double-checking that you are not using any of these{" "} + {singularMediaLabel} on your site. +
@@ -137,14 +152,17 @@ export const DeleteMediaModal = ({ ? singularMediaLabel : `all ${selectedMedia.length} ${pluralMediaLabel}`} - + + + + ) +} + +interface UploadProgressIndicatorProps { + cur: number + total: number +} + +const UploadProgressIndicator = ({ + cur, + total, +}: UploadProgressIndicatorProps) => { + const { onClose } = useModalContext() + + return ( + <> + Upload files + + + + {`Uploading ${cur} of ${total} files`} + + Do not close this screen or navigate away + + + + + + + + + ) +} + +interface MediaUploadSuccessDropzoneProps { + numMedia: number + errorMessages: string[] +} +const MediaUploadSuccessDropzone = ({ + numMedia, + errorMessages, +}: MediaUploadSuccessDropzoneProps) => { + const { onClose } = useModalContext() + + return ( + <> + Files uploaded! + + + + {`Successfully uploaded ${numMedia} files`} + + {errorMessages.length > 0 && ( + <> + + + + {`${errorMessages.length} files failed to upload`} + + + + {errorMessages.map((message) => { + return ( + + {message} + + ) + })} + + + )} + + + + + + ) +} + +interface MediaUploadFailedDropzoneProps { + errorMessages: string[] +} +const MediaUploadFailedDropzone = ({ + errorMessages, +}: MediaUploadFailedDropzoneProps) => { + const { onClose } = useModalContext() + + return ( + <> + Upload failed + + + + + {`${errorMessages.length} ${ + errorMessages.length > 1 ? "files" : "file" + } failed to upload`} + + + {errorMessages.map((message) => { + return ( + + + {message} + + + ) + })} + + + + + + + + + ) +} + +interface MediaCreationModalProps { + onClose: () => void + variant: MediaFolderTypes +} + +interface MediaCreationRouteParams + extends Omit< + MediaDirectoryParams, + "curPage" | "limit" | "mediaDirectoryName" + > { + mediaRoom?: string + mediaDirectoryName?: string +} + +export const MediaCreationModal = ({ + onClose, + variant, +}: MediaCreationModalProps) => { + const { onClose: onModalClose } = useDisclosure() + const params = useParams() + const { siteName, mediaDirectoryName } = params + + const { + mutateAsync: uploadFiles, + numCreated, + numFailed, + errorMessages, + } = useCreateMultipleMedia(siteName, mediaDirectoryName ?? variant) + + const [uploadedFiles, setUploadedFiles] = useState([]) + const [fileRejections, setFileRejections] = useState([]) + const [curStep, setCurStep] = useState("upload") + + useEffect(() => { + if ( + uploadedFiles.length > 0 && + numCreated + numFailed === uploadedFiles.length + ) { + setCurStep("success") + } + }, [numCreated, numFailed, uploadedFiles.length]) + + useEffect(() => { + // NOTE: If every upload failed, + // we display a different modal out to the user + // as there is no success + if (uploadedFiles.length > 0 && uploadedFiles.length === numFailed) { + setCurStep("failed") + } + }, [numFailed, uploadFiles.length, uploadedFiles.length]) + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + { + onClose() + onModalClose() + }} + > + + + + {curStep === "upload" && ( + { + uploadFiles(mediaUploadedFiles) + setCurStep("progressing") + }} + isDisabled={uploadedFiles.length < 1 || curStep !== "upload"} + fileRejections={fileRejections} + uploadedFiles={uploadedFiles} + setUploadedFiles={setUploadedFiles} + setFileRejections={setFileRejections} + /> + )} + {curStep === "progressing" && ( + + )} + {curStep === "success" && ( + + )} + {curStep === "failed" && ( + + )} + + + ) +} diff --git a/src/components/MediaCreationModal/components/Dropzone.tsx b/src/components/MediaCreationModal/components/Dropzone.tsx new file mode 100644 index 000000000..61b1dcb2a --- /dev/null +++ b/src/components/MediaCreationModal/components/Dropzone.tsx @@ -0,0 +1,18 @@ +import { useMultiStyleConfig, Box } from "@chakra-ui/react" +import { PropsWithChildren } from "react" + +import { AttachmentStylesProvider } from "components/Attachment/AttachmentContext" + +export const Dropzone = ({ children }: PropsWithChildren) => { + const styles = useMultiStyleConfig("Attachment") + + return ( + + + + {children} + + + + ) +} diff --git a/src/components/MediaCreationModal/index.js b/src/components/MediaCreationModal/index.ts similarity index 100% rename from src/components/MediaCreationModal/index.js rename to src/components/MediaCreationModal/index.ts diff --git a/src/components/MediaMoveModal/MediaMoveModal.jsx b/src/components/MediaMoveModal/MediaMoveModal.jsx deleted file mode 100644 index df010cbde..000000000 --- a/src/components/MediaMoveModal/MediaMoveModal.jsx +++ /dev/null @@ -1,170 +0,0 @@ -import { CloseButton, HStack } from "@chakra-ui/react" -import _ from "lodash" -import PropTypes from "prop-types" -import { useState } from "react" - -import { Breadcrumb } from "components/folders/Breadcrumb" -import { LoadingButton } from "components/LoadingButton" -import { MoveMenuHeader, DirMenuItem, FileMenuItem } from "components/move" - -import { useListMediaFolderFiles } from "hooks/directoryHooks/useListMediaFolderFiles" -import { useListMediaFolderSubdirectories } from "hooks/directoryHooks/useListMediaFolderSubdirectories" - -import elementStyles from "styles/isomer-cms/Elements.module.scss" - -import { isWriteActionsDisabled } from "utils/reviewRequests" - -import { pageFileNameToTitle, getMediaDirectoryName } from "utils" - -export const MediaMoveModal = ({ queryParams, params, onProceed, onClose }) => { - const { siteName } = params - const [moveQuery, setMoveQuery] = useState(_.omit(queryParams, "fileName")) - const [moveTo, setMoveTo] = useState(params) - - const { data: subDirsData } = useListMediaFolderSubdirectories(moveQuery) - const { data: filesData } = useListMediaFolderFiles(moveQuery) - const isWriteDisabled = isWriteActionsDisabled(siteName) - - const MenuItems = () => { - if (subDirsData && subDirsData?.directories) - return ( - <> - {/* directories */} - {subDirsData?.directories.map(({ name }, itemIndex) => ( - { - setMoveTo({ - ...moveQuery, - mediaDirectoryName: `${getMediaDirectoryName( - moveQuery.mediaDirectoryName, - { joinOn: "/", decode: true } - )}/${name}`, - }) - setMoveQuery((prevState) => ({ - ...prevState, - mediaDirectoryName: `${ - moveQuery.mediaDirectoryName - }%2F${encodeURIComponent(name)}`, - })) - }} - /> - ))} - {/* files */} - {filesData?.files.map(({ name }, itemIndex) => ( - - ))} - - ) - - return ( -
- {subDirsData ? ( - `No ${moveQuery.mediaRoom} here yet.` - ) : ( -
- )} -
- ) - } - - return ( -
-
-
-

{`Move ${moveQuery.mediaRoom.slice(0, -1)}`}

- -
-
-
- {`Moving ${ - moveQuery.mediaRoom - } to a different folder might lead to user confusion. - You may wish to change the permalink or references to this ${moveQuery.mediaRoom.slice( - 0, - -1 - )} afterwards.`} -
-
- Current location:
- -
- { - setMoveQuery({ - ...moveQuery, - mediaDirectoryName: getMediaDirectoryName( - moveQuery.mediaDirectoryName, - { end: -1 } - ), - }) - setMoveTo({ - ...moveQuery, - mediaDirectoryName: getMediaDirectoryName( - moveQuery.mediaDirectoryName, - { end: -1, joinOn: "/", decode: true } - ), - }) - }} - isDisabled={ - moveQuery.mediaDirectoryName.split("%2F").length <= 1 - } // images%2Fdir%2Fdir2 - backButtonText={getMediaDirectoryName( - moveQuery.mediaDirectoryName, - { start: -1, decode: true } - )} - /> - -
- Moving to:
- -
- - - onProceed({ - target: { directoryName: moveTo.mediaDirectoryName }, - items: [{ name: params.fileName, type: "file" }], - }) - } - isDisabled={isWriteDisabled} - > - Move Here - - -
-
-
- ) -} - -export default MediaMoveModal - -MediaMoveModal.propTypes = { - queryParams: PropTypes.shape({ - siteName: PropTypes.string, - mediaDirectoryName: PropTypes.string, - mediaRoom: PropTypes.string, - fileName: PropTypes.string, - }).isRequired, - params: PropTypes.shape({ - siteName: PropTypes.string, - mediaDirectoryName: PropTypes.string, - mediaRoom: PropTypes.string, - fileName: PropTypes.string, - }).isRequired, - onProceed: PropTypes.func, - onClose: PropTypes.func, -} diff --git a/src/components/MediaMoveModal/index.js b/src/components/MediaMoveModal/index.js deleted file mode 100644 index 4bb66f3e9..000000000 --- a/src/components/MediaMoveModal/index.js +++ /dev/null @@ -1 +0,0 @@ -export { MediaMoveModal } from "./MediaMoveModal" diff --git a/src/components/MoveMediaModal/MoveMediaModal.stories.tsx b/src/components/MoveMediaModal/MoveMediaModal.stories.tsx new file mode 100644 index 000000000..709ad0f7b --- /dev/null +++ b/src/components/MoveMediaModal/MoveMediaModal.stories.tsx @@ -0,0 +1,103 @@ +import { useDisclosure } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import type { Meta, StoryFn } from "@storybook/react" +import { MemoryRouter, Route } from "react-router-dom" + +import { getMediaLabels } from "utils/media" + +import { MOCK_MEDIA_SUBDIRECTORY_DATA } from "mocks/constants" +import { handlers } from "mocks/handlers" +import { buildMediaFolderSubdirectoriesData } from "mocks/utils" +import { useSuccessToast } from "utils" + +import { MoveMediaModal } from "./MoveMediaModal" + +const moveMediaModalMeta = { + title: "Components/Move Media Modal", + component: MoveMediaModal, + decorators: [ + (Story) => { + return ( + + + + + + ) + }, + ], +} as Meta + +const moveMediaModalTemplate: StoryFn = ({ + selectedMedia, +}) => { + const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: true }) + const successToast = useSuccessToast() + const onProceed = () => { + successToast({ + id: "storybook-delete-media-success", + description: "STORYBOOK: Media has been successfully moved", + }) + onClose() + } + + return ( + <> + + + + ) +} + +export const Default = moveMediaModalTemplate.bind({}) +Default.parameters = { + msw: { + handlers: [ + ...handlers, + buildMediaFolderSubdirectoriesData(MOCK_MEDIA_SUBDIRECTORY_DATA), + ], + }, +} +Default.args = { + selectedMedia: [ + { filePath: "images/hero-banner.png", size: 1234, sha: "sha1234" }, + ], +} + +export const MultipleMedia = moveMediaModalTemplate.bind({}) +MultipleMedia.parameters = { + msw: { + handlers: [ + ...handlers, + buildMediaFolderSubdirectoriesData(MOCK_MEDIA_SUBDIRECTORY_DATA), + ], + }, +} +MultipleMedia.args = { + selectedMedia: [ + { filePath: "images/hero-banner.png", size: 1234, sha: "sha1234" }, + { filePath: "images/hero-banner-2.png", size: 2345, sha: "sha2345" }, + { filePath: "images/hero-banner-3.png", size: 3456, sha: "sha3456" }, + { filePath: "images/hero-banner-4.png", size: 4567, sha: "sha4567" }, + { filePath: "images/hero-banner-5.png", size: 5678, sha: "sha5678" }, + { filePath: "images/hero-banner-6.png", size: 6789, sha: "sha6789" }, + { filePath: "images/hero-banner-7.png", size: 7890, sha: "sha7890" }, + { filePath: "images/hero-banner-8.png", size: 8901, sha: "sha8901" }, + { filePath: "images/hero-banner-9.png", size: 9012, sha: "sha9012" }, + ], +} + +export default moveMediaModalMeta diff --git a/src/components/MoveMediaModal/MoveMediaModal.tsx b/src/components/MoveMediaModal/MoveMediaModal.tsx new file mode 100644 index 000000000..67fe270b3 --- /dev/null +++ b/src/components/MoveMediaModal/MoveMediaModal.tsx @@ -0,0 +1,330 @@ +import { + Box, + HStack, + Icon, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Skeleton, + Text, + VStack, +} from "@chakra-ui/react" +import { + Button, + Infobox, + ModalCloseButton, +} from "@opengovsg/design-system-react" +import { AxiosError } from "axios" +import _ from "lodash" +import { useEffect, useState } from "react" +import { BiArrowBack, BiFolderOpen } from "react-icons/bi" +import { UseMutateFunction } from "react-query" +import { useParams } from "react-router-dom" + +import { Breadcrumbs } from "components/Breadcrumbs" +import { DirMenuItem, FileMenuItem } from "components/move/MoveMenuItem" + +import { + useListMediaFolderFiles, + useListMediaFolderSubdirectories, +} from "hooks/directoryHooks" + +import { MiddlewareError } from "types/error" +import { + MediaFolderTypes, + MediaLabels, + MoveSelectedMediaDto, + SelectedMediaDto, +} from "types/media" + +interface MoveMediaModalProps { + selectedMedia: SelectedMediaDto[] + mediaType: MediaFolderTypes + mediaLabels: MediaLabels + isWriteDisabled: boolean | undefined + isOpen: boolean + isLoading: boolean + onClose: () => void + onProceed: UseMutateFunction< + void, + AxiosError, + MoveSelectedMediaDto, + unknown + > +} + +const getPathTokens = (filePath: string) => { + return decodeURIComponent(filePath).split("/") +} + +const getLastChildOfPath = (filePath: string) => { + return getPathTokens(filePath).pop() || "" +} + +const getAllParentsOfPath = (filePath: string) => { + return getPathTokens(filePath).slice(0, -1) || [] +} + +export const MoveMediaModal = ({ + selectedMedia, + mediaType, + mediaLabels, + isWriteDisabled, + isOpen, + isLoading, + onClose, + onProceed, +}: MoveMediaModalProps): JSX.Element => { + const { siteName } = useParams<{ siteName: string }>() + const { singularMediaLabel, pluralMediaLabel } = mediaLabels + + const [moveTo, setMoveTo] = useState(mediaType) + + const { + data: mediaSubdirectories, + isLoading: isSubdirectoriesLoading, + } = useListMediaFolderSubdirectories({ + siteName, + mediaDirectoryName: encodeURIComponent(moveTo), + }) + + const { + data: mediaFilesData, + isLoading: isMediaFilesLoading, + } = useListMediaFolderFiles({ + siteName, + mediaDirectoryName: encodeURIComponent(moveTo), + }) + + const onModalClose = () => { + setMoveTo(mediaType) + onClose() + } + + useEffect(() => { + // Note: This useEffect is needed to reset the state of the modal when it + // is closed and reopened. This is because the modal is not unmounted when + // it is closed. + setMoveTo(mediaType) + }, [mediaType, isOpen]) + + return ( + + + + + + + + Move{" "} + {selectedMedia.length === 1 + ? getLastChildOfPath(selectedMedia[0].filePath) + : `${selectedMedia.length} ${pluralMediaLabel}`} + + + + + + + Moving an {singularMediaLabel} to a different folder might lead to + broken {pluralMediaLabel} on pages. This confuses site visitors. + Ensure that your pages are displaying the correct{" "} + {pluralMediaLabel}. + + + {selectedMedia.length === 1 && ( + + + {_.upperFirst(singularMediaLabel)} is currently located in + + + ({ + title: _.upperFirst(item), + }) + ), + { + title: getLastChildOfPath(selectedMedia[0].filePath), + }, + ]} + maxBreadcrumbsLength={4} + spacing="0rem" + textStyle="body-2" + isLinked={false} + isLastChildUnderlined + /> + + + )} + + + + + {moveTo === mediaType ? ( + + ) : ( + + )} + + {_.upperFirst(getLastChildOfPath(moveTo) || mediaType)} + + + + + + + {/* Directories */} + {mediaSubdirectories?.directories.map( + (subdirectory, itemIndex) => ( + { + setMoveTo([moveTo, subdirectory.name].join("/")) + }} + /> + ) + )} + + {/* Files */} + {mediaFilesData?.files.map((file, itemIndex) => ( + + ))} + + {/* Empty state */} + {mediaSubdirectories?.directories.length === 0 && + mediaFilesData?.files.length === 0 && ( + + + No {pluralMediaLabel} here yet. + + + )} + + + + + + + Move{" "} + {selectedMedia.length === 1 + ? singularMediaLabel + : pluralMediaLabel}{" "} + to + + + {/* Display the file name when only one file is moved */} + {selectedMedia.length === 1 && ( + ({ + title: _.upperFirst(decodeURIComponent(item)), + })), + { + title: getLastChildOfPath(selectedMedia[0].filePath), + }, + ]} + maxBreadcrumbsLength={4} + spacing="0rem" + textStyle="body-2" + isLinked={false} + isLastChildUnderlined + /> + )} + + {/* Omit the file name when multiple files are moved */} + {selectedMedia.length > 1 && ( + ({ + title: _.upperFirst(decodeURIComponent(item)), + })), + ]} + spacing="0rem" + textStyle="body-2" + isLinked={false} + isLastChildUnderlined + /> + )} + + + + + + + + + + + + + + ) +} diff --git a/src/components/MoveMediaModal/index.ts b/src/components/MoveMediaModal/index.ts new file mode 100644 index 000000000..26089621f --- /dev/null +++ b/src/components/MoveMediaModal/index.ts @@ -0,0 +1 @@ +export * from "./MoveMediaModal" diff --git a/src/components/media/MediaModal.jsx b/src/components/media/MediaModal.jsx index 88a73f9c6..ec3f7ebcd 100644 --- a/src/components/media/MediaModal.jsx +++ b/src/components/media/MediaModal.jsx @@ -63,6 +63,7 @@ const MediaModal = ({ onClose, onProceed, type, showAltTextModal = false }) => { return ( { await createHandler({ data }) onMediaSelect({ ...data, mediaUrl: data.content }) diff --git a/src/components/move/MoveMenuItem.tsx b/src/components/move/MoveMenuItem.tsx index 4719a9e95..135848a38 100644 --- a/src/components/move/MoveMenuItem.tsx +++ b/src/components/move/MoveMenuItem.tsx @@ -1,15 +1,13 @@ -import { Spacer, Flex, Text, Button, Icon } from "@chakra-ui/react" +import { Box, Button, Flex, HStack, Icon, Spacer, Text } from "@chakra-ui/react" import { MouseEventHandler } from "react" +import { BiChevronRight, BiFileBlank, BiFolder } from "react-icons/bi" -import elementStyles from "styles/isomer-cms/Elements.module.scss" - -import { BxFileBlank, BxFolder, BxChevronRight } from "assets/icons" -import { pageFileNameToTitle, deslugifyDirectory } from "utils" +import { deslugifyDirectory, pageFileNameToTitle } from "utils" export interface FileMenuItemProps { name: string - id: string - isResource: boolean + id: string | number + isResource?: boolean } export const FileMenuItem = ({ @@ -18,22 +16,25 @@ export const FileMenuItem = ({ isResource = false, }: FileMenuItemProps): JSX.Element => { return ( -
- - {pageFileNameToTitle(name, isResource)} -
+ + + {pageFileNameToTitle(name, isResource)} + + ) } export interface DirMenuItemProps { name: string - id: string + id: string | number onClick: MouseEventHandler } @@ -43,40 +44,42 @@ export const DirMenuItem = ({ onClick, }: DirMenuItemProps): JSX.Element => { return ( - <> - - + ) } diff --git a/src/hooks/directoryHooks/index.ts b/src/hooks/directoryHooks/index.ts index 19faac8fe..5b51c40a9 100644 --- a/src/hooks/directoryHooks/index.ts +++ b/src/hooks/directoryHooks/index.ts @@ -12,3 +12,5 @@ export * from "./useGetWorkspacePages" export * from "./useGetResourceRoomName" export * from "./useUpdateResourceRoomName" export * from "./useGetAllDirectoryPages" +export * from "./useListMediaFolderSubdirectories" +export * from "./useListMediaFolderFiles" diff --git a/src/hooks/mediaHooks/useCreateMultipleMedia.ts b/src/hooks/mediaHooks/useCreateMultipleMedia.ts new file mode 100644 index 000000000..58f71e697 --- /dev/null +++ b/src/hooks/mediaHooks/useCreateMultipleMedia.ts @@ -0,0 +1,89 @@ +import { AxiosError } from "axios" +import { useState } from "react" +import { UseMutationResult, useMutation, useQueryClient } from "react-query" + +import { LIST_MEDIA_DIRECTORY_FILES_KEY } from "constants/queryKeys" + +import * as MediaFileService from "services/MediaFileService" + +import { getAxiosErrorMessage } from "utils/axios" + +import { MediaData } from "types/directory" +import { getFileExt, getFileName } from "utils" + +const readFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + resolve(reader.result as string) + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +export const useCreateMultipleMedia = ( + siteName: string, + mediaDirectoryName: string +): UseMutationResult & { + numCreated: number + numFailed: number + errorMessages: string[] +} => { + const queryClient = useQueryClient() + const [numCreated, setNumCreated] = useState(0) + const [numFailed, setNumFailed] = useState(0) + const [errorMessages, setErrorMessages] = useState([]) + + const res: UseMutationResult = useMutation( + // data -> sha, name, content + // content is data url + async (files) => { + const updatedFiles: MediaData[] = [] + + const mediaData = await Promise.all( + files.map(async (file) => ({ + content: await readFile(file), + // NOTE: Replace all non-word and whitespace + // with a safe replacement character + newFileName: `${getFileName(file.name) + .replaceAll(/[\W\s]/g, "_") + .trim()}.${getFileExt(file.name)}`, + })) + ) + + await mediaData.reduce((acc, cur) => { + return acc + .then(() => { + return MediaFileService.createMediaFile( + siteName, + mediaDirectoryName, + cur + ) + }) + .then((data) => { + updatedFiles.push(data) + setNumCreated((prev) => prev + 1) + }) + .catch((axiosError) => { + setNumFailed((prev) => prev + 1) + setErrorMessages((prev) => [ + ...prev, + getAxiosErrorMessage(axiosError), + ]) + }) + .then(() => + queryClient.invalidateQueries([ + // invalidates media directory + LIST_MEDIA_DIRECTORY_FILES_KEY, + { siteName, mediaDirectoryName }, + ]) + ) + .finally(() => new Promise((resolve) => setTimeout(resolve, 500))) + }, Promise.resolve()) + return updatedFiles + } + ) + + return { ...res, numCreated, numFailed, errorMessages } +} diff --git a/src/hooks/moveHooks/index.js b/src/hooks/moveHooks/index.js deleted file mode 100644 index f8be60b9b..000000000 --- a/src/hooks/moveHooks/index.js +++ /dev/null @@ -1 +0,0 @@ -export { useMoveHook } from "./useMoveHook" diff --git a/src/hooks/moveHooks/index.ts b/src/hooks/moveHooks/index.ts new file mode 100644 index 000000000..22ec29f8c --- /dev/null +++ b/src/hooks/moveHooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useMoveHook" +export * from "./useMoveMultipleMediaHook" diff --git a/src/hooks/moveHooks/useMoveMultipleMediaHook.tsx b/src/hooks/moveHooks/useMoveMultipleMediaHook.tsx new file mode 100644 index 000000000..ffe6e3967 --- /dev/null +++ b/src/hooks/moveHooks/useMoveMultipleMediaHook.tsx @@ -0,0 +1,112 @@ +import { AxiosError } from "axios" +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQueryClient, +} from "react-query" + +import { + GET_ALL_MEDIA_FILES_KEY, + LIST_MEDIA_DIRECTORY_FILES_KEY, + LIST_MEDIA_FOLDERS_KEY, +} from "constants/queryKeys" + +import { apiService } from "services/ApiService" + +import { MoverService } from "services" +import { MiddlewareError } from "types/error" +import { MediaDirectoryParams } from "types/folders" +import { MoveMultipleMediaDto, MoveSelectedMediaDto } from "types/media" + +const moveMultipleMedia = async ( + { siteName }: MediaDirectoryParams, + { target, items }: MoveSelectedMediaDto +) => { + const moveService = new MoverService({ apiClient: apiService }) + + await items + .map((item) => { + const pathTokens = item.filePath.split("/") + + return { + source: pathTokens.slice(0, -1).join("%2F"), + target, + name: pathTokens.pop() || "", + } + }) + .reduce((acc, curr) => { + // Combine all files with the same source directory into one object + const existing = acc.find((item) => item.source === curr.source) + if (existing) { + existing.items.push({ name: curr.name, type: "file" }) + } else { + acc.push({ + source: curr.source, + target: curr.target, + items: [{ name: curr.name, type: "file" }], + }) + } + return acc + }, []) + .reduce( + (acc, curr) => + acc + .then(() => { + const params = { + siteName, + mediaDirectoryName: curr.source, + } + const body = { + target: curr.target, + items: curr.items, + } + return moveService.move(params, body).catch((error) => { + // We want to continue even if some of the media files fail to + // move because there is no turning back now if we fail (the + // earlier files would have been moved) + return error + }) as Promise + }) + // This wait is necessary to avoid the repo lock + .then(() => new Promise((resolve) => setTimeout(resolve, 500))), + Promise.resolve() + ) +} + +export const useMoveMultipleMediaHook = ( + params: MediaDirectoryParams, + mutationOptions?: Omit< + UseMutationOptions, MoveSelectedMediaDto>, + "mutationFn" | "mutationKey" + > +): UseMutationResult< + void, + AxiosError, + MoveSelectedMediaDto +> => { + const queryClient = useQueryClient() + + return useMutation, MoveSelectedMediaDto>( + (moveSelectedMediaDto) => moveMultipleMedia(params, moveSelectedMediaDto), + { + ...mutationOptions, + onSettled: (data, error, variables, context) => { + queryClient.invalidateQueries([LIST_MEDIA_DIRECTORY_FILES_KEY]) + queryClient.invalidateQueries([GET_ALL_MEDIA_FILES_KEY]) + queryClient.invalidateQueries([LIST_MEDIA_FOLDERS_KEY]) + + if (mutationOptions && mutationOptions.onSettled) + mutationOptions.onSettled(data, error, variables, context) + }, + onSuccess: (data, variables, context) => { + if (mutationOptions?.onSuccess) + mutationOptions.onSuccess(data, variables, context) + }, + onError: (err, variables, context) => { + if (mutationOptions?.onError) + mutationOptions.onError(err, variables, context) + }, + } + ) +} diff --git a/src/layouts/Media/Media.tsx b/src/layouts/Media/Media.tsx index bd53a3016..a9376fa94 100644 --- a/src/layouts/Media/Media.tsx +++ b/src/layouts/Media/Media.tsx @@ -19,19 +19,21 @@ import { } from "@opengovsg/design-system-react" import _ from "lodash" import { useEffect, useState } from "react" -import { BiFolderOpen, BiFolderPlus, BiTrash } from "react-icons/bi" +import { BiFolderOpen, BiTrash } from "react-icons/bi" import { Link, Switch, useHistory, useRouteMatch } from "react-router-dom" import { DeleteMediaModal } from "components/DeleteMediaModal" import { Greyscale } from "components/Greyscale" import { ImagePreviewCard } from "components/ImagePreviewCard" +import { MoveMediaModal } from "components/MoveMediaModal" -import { MEDIA_PAGINATION_SIZE, MAX_MEDIA_LEVELS } from "constants/media" +import { MAX_MEDIA_LEVELS, MEDIA_PAGINATION_SIZE } from "constants/media" import { useGetAllMediaFiles } from "hooks/directoryHooks/useGetAllMediaFiles" import { useListMediaFolderFiles } from "hooks/directoryHooks/useListMediaFolderFiles" import { useListMediaFolderSubdirectories } from "hooks/directoryHooks/useListMediaFolderSubdirectories" import { useDeleteMultipleMediaHook } from "hooks/mediaHooks" +import { useMoveMultipleMediaHook } from "hooks/moveHooks" import { usePaginate } from "hooks/usePaginate" import { DeleteWarningScreen } from "layouts/screens/DeleteWarningScreen" @@ -39,7 +41,6 @@ import { DirectoryCreationScreen } from "layouts/screens/DirectoryCreationScreen import { DirectorySettingsScreen } from "layouts/screens/DirectorySettingsScreen" import { MediaCreationScreen } from "layouts/screens/MediaCreationScreen" import { MediaSettingsScreen } from "layouts/screens/MediaSettingsScreen" -import { MoveScreen } from "layouts/screens/MoveScreen" import { ProtectedRouteWithProps } from "routing/ProtectedRouteWithProps" @@ -48,7 +49,7 @@ import { isWriteActionsDisabled } from "utils/reviewRequests" import { EmptyAlbumImage, EmptyDirectoryImage } from "assets" import { MediaData } from "types/directory" -import { MediaLabels, SelectedMediaDto } from "types/media" +import { MediaFolderTypes, MediaLabels, SelectedMediaDto } from "types/media" import { DEFAULT_RETRY_MSG, useErrorToast, useSuccessToast } from "utils" import { CreateButton } from "../components" @@ -147,7 +148,7 @@ export const Media = (): JSX.Element => { const [curPage, setCurPage] = usePaginate() const { params, path, url } = useRouteMatch<{ siteName: string - mediaRoom: "files" | "images" + mediaRoom: MediaFolderTypes mediaDirectoryName: string }>() const { siteName, mediaRoom: mediaType, mediaDirectoryName } = params @@ -160,6 +161,11 @@ export const Media = (): JSX.Element => { setIndividualMedia, ] = useState(null) + const { + isOpen: isMoveModalOpen, + onOpen: onMoveModalOpen, + onClose: onMoveModalClose, + } = useDisclosure() const { isOpen: isDeleteModalOpen, onOpen: onDeleteModalOpen, @@ -242,6 +248,32 @@ export const Media = (): JSX.Element => { params.mediaDirectoryName ) + const { + mutate: moveMultipleMedia, + isLoading: isMoveMultipleMediaLoading, + } = useMoveMultipleMediaHook(params, { + onSettled: () => { + setSelectedMedia([]) + onMoveModalClose() + }, + onSuccess: (data, variables, context) => { + successToast({ + id: "move-multiple-media-success", + description: `Successfully moved ${ + variables.items.length === 1 ? singularMediaLabel : pluralMediaLabel + }!`, + }) + }, + onError: (err, variables, context) => { + errorToast({ + id: "move-multiple-media-error", + description: `Your ${ + variables.items.length === 1 ? singularMediaLabel : pluralMediaLabel + } could not be moved successfully. ${DEFAULT_RETRY_MSG}`, + }) + }, + }) + const { mutate: deleteMultipleMedia, isLoading: isDeleteMultipleMediaLoading, @@ -287,6 +319,20 @@ export const Media = (): JSX.Element => { return ( <> + { + setIndividualMedia(null) + onMoveModalClose() + }} + onProceed={moveMultipleMedia} + /> + { /> - + {/* Header section */} {/* Page title segment */} - All {mediaType} + {mediaType === mediaDirectoryName + ? `All ${mediaType}` + : _.upperFirst(mediaDirectoryName.split("%2F").pop())} {getSubheadText( @@ -325,62 +373,56 @@ export const Media = (): JSX.Element => { {/* Action buttons segment */} - {(subDirCount !== 0 || filesCount !== 0) && ( + {selectedMedia.length > 0 && ( <> - {selectedMedia.length > 0 && ( - <> - - - - - setIndividualMedia(null)} + + + + + setIndividualMedia(null)} + placement="bottom-end" + > + + Edit selected + - - Edit selected - - {selectedMedia.length} - - - - {/* FIXME: To add back when flow is available - - - Move images to album - */} - {/* FIXME: To add back when flow is available + {selectedMedia.length} + + + + onMoveModalOpen()}> + + Move images to album + + {/* FIXME: To add back when flow is available { /> Create new album with images */} - onDeleteModalOpen()} - > - - Delete all - - - - - - )} + onDeleteModalOpen()} + > + + Delete all + + + + + + )} - {selectedMedia.length === 0 && ( - <> - - {directoryLevel >= MAX_MEDIA_LEVELS ? ( - - - - ) : ( + {(subDirCount !== 0 || filesCount !== 0) && + selectedMedia.length === 0 && ( + <> + + + + {directoryLevel >= MAX_MEDIA_LEVELS ? ( + - )} - - - - - - - - )} - - )} + + ) : ( + + )} + + + + + + + + )} {/* Subdirectories section */} - - - {mediaFolderSubdirectories?.directories.map(({ name }) => { - return - })} - - + {(isListMediaFolderSubdirectoriesLoading || + (mediaFolderSubdirectories?.directories?.length && + mediaFolderSubdirectories?.directories.length > 0)) && ( + + + {mediaFolderSubdirectories?.directories.map(({ name }) => { + return + })} + + + )} {/* Media section */} 0 + ? "2.25rem" + : "2rem" + } > {subDirCount === 0 && filesCount === 0 ? (
@@ -535,6 +590,7 @@ export const Media = (): JSX.Element => { setIndividualMedia(getSelectedMediaDto(data)) } onDelete={onDeleteModalOpen} + onMove={onMoveModalOpen} /> ) )} @@ -585,11 +641,6 @@ export const Media = (): JSX.Element => { component={DirectorySettingsScreen} onClose={() => history.goBack()} /> - history.goBack()} - /> ) diff --git a/src/layouts/Media/components/FilePreviewCard.tsx b/src/layouts/Media/components/FilePreviewCard.tsx index af73bae5f..1cfd0629e 100644 --- a/src/layouts/Media/components/FilePreviewCard.tsx +++ b/src/layouts/Media/components/FilePreviewCard.tsx @@ -19,12 +19,14 @@ interface FilePreviewCardProps { name: string onOpen?: () => void onDelete?: () => void + onMove?: () => void } export const FilePreviewCard = ({ name, onOpen, onDelete, + onMove, }: FilePreviewCardProps): JSX.Element => { const { url } = useRouteMatch() const encodedName = encodeURIComponent(name) @@ -56,11 +58,7 @@ export const FilePreviewCard = ({ > Edit details - } - as={RouterLink} - to={`${url}/moveMedia/${encodedName}`} - > + } onClick={onMove}> Move to diff --git a/src/layouts/Media/components/MediaBreadcrumb.tsx b/src/layouts/Media/components/MediaBreadcrumb.tsx index 485c7ce38..3fd5ac1e5 100644 --- a/src/layouts/Media/components/MediaBreadcrumb.tsx +++ b/src/layouts/Media/components/MediaBreadcrumb.tsx @@ -1,6 +1,6 @@ -import { Breadcrumb, BreadcrumbItem, BreadcrumbLink } from "@chakra-ui/react" -import { BiChevronRight } from "react-icons/bi" -import { useRouteMatch, Link as RouterLink } from "react-router-dom" +import { useRouteMatch } from "react-router-dom" + +import { BreadcrumbItem, Breadcrumbs } from "components/Breadcrumbs" import { MAX_MEDIA_BREADCRUMBS_LENGTH } from "constants/media" @@ -19,73 +19,33 @@ export const MediaBreadcrumbs = (): JSX.Element => { }>() const directories = mediaDirectoryName.split("%2F") const firstItem = directories[0] + const breadcrumbItems = directories.slice(1).reduce( + (mediaDirectoriesInfo, currentMediaDirectoryInfo) => { + const prev = mediaDirectoriesInfo[mediaDirectoriesInfo.length - 1] + const directoryUrl = `${prev.url}%2F${currentMediaDirectoryInfo}` + const displayedDirectoryName = deslugifyDirectory( + currentMediaDirectoryInfo + ) + return [ + ...mediaDirectoriesInfo, + { + title: displayedDirectoryName, + url: directoryUrl, + }, + ] + }, + [ + { + title: deslugifyDirectory(firstItem), + url: `/sites/${siteName}/media/${mediaType}/mediaDirectory/${firstItem}`, + }, + ] + ) return ( - } - > - {directories - .slice(1) - .reduce( - (mediaDirectoriesInfo, currentMediaDirectoryInfo) => { - const prev = mediaDirectoriesInfo[mediaDirectoriesInfo.length - 1] - const directoryUrl = `${prev.url}%2F${currentMediaDirectoryInfo}` - const displayedDirectoryName = deslugifyDirectory( - currentMediaDirectoryInfo - ) - return [ - ...mediaDirectoriesInfo, - { - name: displayedDirectoryName, - url: directoryUrl, - }, - ] - }, - [ - { - name: deslugifyDirectory(firstItem), - url: `/sites/${siteName}/media/${mediaType}/mediaDirectory/${firstItem}`, - }, - ] - ) - .map(({ name, url }, idx) => { - // Note: Intermediate albums/directories are not shown in the breadcrumbs - // but it only affects users that are nesting more than - // MAX_MEDIA_BREADCRUMBS_LENGTH levels deep - if ( - directories.length > MAX_MEDIA_BREADCRUMBS_LENGTH && - idx > 1 && - idx < directories.length - (MAX_MEDIA_BREADCRUMBS_LENGTH - 1) - ) { - return <> - } - - const isEllipsis = - directories.length > MAX_MEDIA_BREADCRUMBS_LENGTH && idx === 1 - const hasModifier = idx === directories.length - 1 || isEllipsis - - return ( - - - {isEllipsis ? "..." : name} - - - ) - })} - + ) } diff --git a/src/layouts/components/Editor/components/MenuBar.tsx b/src/layouts/components/Editor/components/MenuBar.tsx index 03b20b21d..26cfaa895 100644 --- a/src/layouts/components/Editor/components/MenuBar.tsx +++ b/src/layouts/components/Editor/components/MenuBar.tsx @@ -176,7 +176,7 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { borderTopRadius="0.25rem" > {items.map((item) => ( - + <> {item.type === "divider" ? ( { ) : ( )} - + ))} ) diff --git a/src/layouts/screens/DeleteWarningScreen.jsx b/src/layouts/screens/DeleteWarningScreen.jsx index 2d388014e..779bb729c 100644 --- a/src/layouts/screens/DeleteWarningScreen.jsx +++ b/src/layouts/screens/DeleteWarningScreen.jsx @@ -1,6 +1,7 @@ import { Text } from "@chakra-ui/react" -import { Button } from "@opengovsg/design-system-react" +import { Button, Checkbox } from "@opengovsg/design-system-react" import PropTypes from "prop-types" +import { useState } from "react" import { LoadingButton } from "components/LoadingButton" import { WarningModal } from "components/WarningModal" @@ -18,6 +19,7 @@ import { } from "utils" export const DeleteWarningScreen = ({ match, onClose }) => { + const [isDeleteChecked, setIsDeleteChecked] = useState(false) const { params, decodedParams } = match const { siteName, fileName, mediaRoom } = params const deleteItemName = params.mediaDirectoryName @@ -26,6 +28,7 @@ export const DeleteWarningScreen = ({ match, onClose }) => { decodedParams[getLastItemType(decodedParams)], !!(params.resourceRoomName && params.fileName) ) + const isWriteDisabled = isWriteActionsDisabled(siteName) if (fileName) { @@ -40,22 +43,33 @@ export const DeleteWarningScreen = ({ match, onClose }) => { onSuccess: () => onClose(), }) + const mediaType = mediaRoom === "images" ? "image" : "file" + return ( Are you sure you want to delete {fileName}?} + displayTitle={`Delete ${fileName}?`} + displayText={ + + Are you sure you want to delete this {mediaType}? If you used this{" "} + {mediaType} on any page, site visitors may see a broken {mediaType}. + This cannot be undone. + + } > + setIsDeleteChecked(e.target.checked)}> + Yes, delete {mediaType} + deleteHandler({ sha: fileData.sha })} - isDisabled={isWriteDisabled} + isDisabled={isWriteDisabled || !isDeleteChecked} > - Yes, delete + Delete {mediaType} ) @@ -65,24 +79,37 @@ export const DeleteWarningScreen = ({ match, onClose }) => { onSuccess: () => onClose(), }) + const mediaType = + mediaRoom === "images" + ? { dirType: "album", resourceType: "image" } + : { dirType: "folder", resourceType: "file" } + return ( Are you sure you want to delete {deleteItemName}? + + Are you sure you want to delete this {mediaType.dirType} and all its + child {mediaType.dirType}s and {mediaType.resourceType}s? If you used + its child contents on any page, site visitors may see a broken{" "} + {mediaType.resourceType}. This cannot be undone. + } > + setIsDeleteChecked(e.target.checked)}> + Yes, delete this {mediaType.dirType} and all its contents + - Yes, delete + Delete {mediaType.dirType} ) diff --git a/src/layouts/screens/MediaCreationScreen.jsx b/src/layouts/screens/MediaCreationScreen.jsx index f6cec3fd8..2aa0ce876 100644 --- a/src/layouts/screens/MediaCreationScreen.jsx +++ b/src/layouts/screens/MediaCreationScreen.jsx @@ -14,6 +14,7 @@ export const MediaCreationScreen = ({ match, onClose }) => { return ( diff --git a/src/layouts/screens/MoveScreen.jsx b/src/layouts/screens/MoveScreen.jsx index 55f85a2d5..64e43cc2b 100644 --- a/src/layouts/screens/MoveScreen.jsx +++ b/src/layouts/screens/MoveScreen.jsx @@ -1,36 +1,23 @@ import _ from "lodash" import PropTypes from "prop-types" -import { MediaMoveModal } from "components/MediaMoveModal" import { PageMoveModal } from "components/PageMoveModal" import { useMoveHook } from "hooks/moveHooks" export const MoveScreen = ({ match, onClose }) => { const { params, decodedParams } = match - const { mediaRoom } = params const { mutateAsync: moveHandler } = useMoveHook(_.omit(params, "fileName"), { onSuccess: () => onClose(), }) return ( - <> - {mediaRoom ? ( - - ) : ( - - )} - + ) } diff --git a/src/mocks/constants.ts b/src/mocks/constants.ts index 02622e190..f194dd1a9 100644 --- a/src/mocks/constants.ts +++ b/src/mocks/constants.ts @@ -114,7 +114,7 @@ export const MOCK_USER: LoggedInUser = { } export const MOCK_MEDIA_ITEM_ONE: MediaData = { - mediaPath: "/some/thing", + mediaPath: "images/some/thing", mediaUrl: "https://www.thebrandingjournal.com/wp-content/uploads/2014/06/20-Funny-Shocked-Cat-Memes-3.jpg", name: "shocked cat", @@ -125,7 +125,7 @@ export const MOCK_MEDIA_ITEM_ONE: MediaData = { } export const MOCK_MEDIA_ITEM_TWO: MediaData = { - mediaPath: "/some/thing", + mediaPath: "images/some/thing", mediaUrl: "https://uploads.dailydot.com/2018/10/olli-the-polite-cat.jpg", name: "polite cat", sha: "sha", @@ -135,7 +135,7 @@ export const MOCK_MEDIA_ITEM_TWO: MediaData = { } export const MOCK_MEDIA_ITEM_THREE: MediaData = { - mediaPath: "/some/thing", + mediaPath: "images/some/thing", mediaUrl: "https://img-9gag-fun.9cache.com/photo/a2W12m9_700bwp.webp", name: "screaming cat", sha: "sha", @@ -145,7 +145,7 @@ export const MOCK_MEDIA_ITEM_THREE: MediaData = { } export const MOCK_MEDIA_ITEM_FOUR: MediaData = { - mediaPath: "/some/thing", + mediaPath: "images/some/thing", mediaUrl: "https://cdn.pixabay.com/photo/2012/08/27/14/19/mountains-55067__340.png", name: "some mountain", @@ -156,7 +156,7 @@ export const MOCK_MEDIA_ITEM_FOUR: MediaData = { } export const MOCK_MEDIA_ITEM_FIVE: MediaData = { - mediaPath: "/some/thing", + mediaPath: "images/some/thing", mediaUrl: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8cG9ydHJhaXR8ZW58MHx8MHx8&w=1000&q=80.png", name: "pearly", @@ -169,19 +169,19 @@ export const MOCK_MEDIA_ITEM_FIVE: MediaData = { export const MOCK_MEDIA_ITEM_DATA: GetMediaFilesDto = { files: [ Object.assign(_.omit(MOCK_MEDIA_ITEM_ONE, "mediaPath"), { - path: "/some/thing", + path: "images/some/thing", }), Object.assign(_.omit(MOCK_MEDIA_ITEM_TWO, "mediaPath"), { - path: "/some/thing", + path: "images/some/thing", }), Object.assign(_.omit(MOCK_MEDIA_ITEM_THREE, "mediaPath"), { - path: "/some/thing", + path: "images/some/thing", }), Object.assign(_.omit(MOCK_MEDIA_ITEM_FOUR, "mediaPath"), { - path: "/some/thing", + path: "images/some/thing", }), Object.assign(_.omit(MOCK_MEDIA_ITEM_FIVE, "mediaPath"), { - path: "/some/thing", + path: "images/some/thing", }), ], total: 5, diff --git a/src/services/MediaFileService.ts b/src/services/MediaFileService.ts index fc4c756b1..f26a0e819 100644 --- a/src/services/MediaFileService.ts +++ b/src/services/MediaFileService.ts @@ -1,4 +1,5 @@ import { MediaData } from "types/directory" +import { MediaCreationInfo } from "types/media" import { apiService } from "./ApiService" @@ -10,3 +11,14 @@ export const getMediaFile = ( const endpoint = `/sites/${siteName}/media/${mediaDirectoryName}/pages/${fileName}` return apiService.get(endpoint).then(({ data }) => data) } + +export const createMediaFile = async ( + siteName: string, + mediaDirectoryName: string, + mediaData: MediaCreationInfo +): Promise => { + const endpoint = `/sites/${siteName}/media/${mediaDirectoryName}/pages` + return apiService + .post(endpoint, mediaData) + .then(({ data }) => data) +} diff --git a/src/types/media.ts b/src/types/media.ts index 631ea6bcd..6796791aa 100644 --- a/src/types/media.ts +++ b/src/types/media.ts @@ -1,4 +1,4 @@ -import { MediaService } from "services" +export type MediaFolderTypes = "files" | "images" export interface MultipleMediaParams { siteName: string @@ -18,3 +18,21 @@ export interface SelectedMediaDto { size: number sha: string } + +export interface MediaCreationInfo { + content: string + newFileName: string +} + +export interface MoveSelectedMediaDto { + target: { + directoryName: string + } + items: SelectedMediaDto[] +} + +export interface MoveMultipleMediaDto { + source: string + target: { directoryName: string } + items: Array<{ name: string; type: "file" }> +}