From f5387ec6886fcf490c5032cfa53a3643060d29bd Mon Sep 17 00:00:00 2001 From: Harish Date: Wed, 8 Nov 2023 17:09:36 +0800 Subject: [PATCH 1/6] IS-685 enhance delete images (#1631) * feat: copy changes * feat: add checkbox confirmation * feat: enhance modal for folder deletion * remove console log --- src/layouts/screens/DeleteWarningScreen.jsx | 45 ++++++++++++++++----- 1 file changed, 36 insertions(+), 9 deletions(-) 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} ) From bcb1479d258d0cf5db899416aea2054b68c28703 Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Thu, 9 Nov 2023 11:47:51 +0800 Subject: [PATCH 2/6] feat(media): introduce new move media modal (#1652) * fix(delete): reset delete check on modal closure * feat(breadcrumbs): generalise media breadcrumbs * chore(assets): add new BxErrorSolid icon * feat(media): introduce new move media modal * feat(media): add new move multiple media hook * feat(media): update media page to support new move modal * style(media): update styling based on design feedback * style(media): place image name inside modal title * fix(media): update object structure to match backend requirements * chore(media): remove old media move modal * style(media): truncate if directory name is too long * style(media): update media layout styles based on design feedback * fix(media): abstract path handling to utility functions and add comment --- src/assets/icons/BxErrorSolid.tsx | 19 + src/assets/icons/index.ts | 1 + .../Breadcrumbs/Breadcrumbs.stories.tsx | 91 +++++ src/components/Breadcrumbs/Breadcrumbs.tsx | 108 ++++++ src/components/Breadcrumbs/index.ts | 1 + .../DeleteMediaModal/DeleteMediaModal.tsx | 46 ++- .../ImagePreviewCard/ImagePreviewCard.tsx | 16 +- .../MediaMoveModal/MediaMoveModal.jsx | 170 --------- src/components/MediaMoveModal/index.js | 1 - .../MoveMediaModal/MoveMediaModal.stories.tsx | 103 ++++++ .../MoveMediaModal/MoveMediaModal.tsx | 330 ++++++++++++++++++ src/components/MoveMediaModal/index.ts | 1 + src/components/move/MoveMenuItem.tsx | 105 +++--- src/hooks/directoryHooks/index.ts | 2 + src/hooks/moveHooks/index.js | 1 - src/hooks/moveHooks/index.ts | 2 + .../moveHooks/useMoveMultipleMediaHook.tsx | 112 ++++++ src/layouts/Media/Media.tsx | 291 ++++++++------- .../Media/components/FilePreviewCard.tsx | 8 +- .../Media/components/MediaBreadcrumb.tsx | 98 ++---- src/layouts/screens/MoveScreen.jsx | 25 +- src/mocks/constants.ts | 20 +- src/types/media.ts | 15 +- 23 files changed, 1091 insertions(+), 475 deletions(-) create mode 100644 src/assets/icons/BxErrorSolid.tsx create mode 100644 src/components/Breadcrumbs/Breadcrumbs.stories.tsx create mode 100644 src/components/Breadcrumbs/Breadcrumbs.tsx create mode 100644 src/components/Breadcrumbs/index.ts delete mode 100644 src/components/MediaMoveModal/MediaMoveModal.jsx delete mode 100644 src/components/MediaMoveModal/index.js create mode 100644 src/components/MoveMediaModal/MoveMediaModal.stories.tsx create mode 100644 src/components/MoveMediaModal/MoveMediaModal.tsx create mode 100644 src/components/MoveMediaModal/index.ts delete mode 100644 src/hooks/moveHooks/index.js create mode 100644 src/hooks/moveHooks/index.ts create mode 100644 src/hooks/moveHooks/useMoveMultipleMediaHook.tsx 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/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}`} - + + + ) +} + +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/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/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/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/types/media.ts b/src/types/media.ts index 631ea6bcd..c20586dd1 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,16 @@ export interface SelectedMediaDto { size: number sha: string } + +export interface MoveSelectedMediaDto { + target: { + directoryName: string + } + items: SelectedMediaDto[] +} + +export interface MoveMultipleMediaDto { + source: string + target: { directoryName: string } + items: Array<{ name: string; type: "file" }> +} From 278e210d9a4de4ed2f485e4a18d7aaa1f6468771 Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Thu, 9 Nov 2023 12:11:05 +0800 Subject: [PATCH 3/6] feat(images): bulk upload (#1654) * feat(mediafileservice): add new endpoint for creating single file * refactor(attachment): add new section for errrors * refactor(attachmentdropzone): add button link * feat(media): add new types * feat(hooks): add new hook to create multiple media * feat(media-creation-components): add new components for modal * refactor(mediacreationmodal): add new flow for bulk creation * feat(mediacreationmodal): add prog bar * refactor(attachment): remove max # of uploads * refactor(creation-modal): update copyu * refactor(attachment): remove extra text * refactor(creation-modal): add on more when reupload * refactor(attachment): update copy * refactor(attachment-file-info): update styling to avoid overflow into multiple lines * fix(attachmenterror): update styling to prevent text overflow * refactor(attachmenterror): update styling * refactor(attachment): update divider logic * refactor(mediacreationmodal): inline error list * refactor(mediacreationmodal): remove import * refactor(media-ceration): change dropzone to update accept dynamically * refactor(media-creation): update naming * fix(attachment): update overflow behaviour * refactor(mediacreaitonmodal): update naming * fix(mediacreationmodal): update types --- src/components/Attachment/Attachment.tsx | 64 ++-- .../Attachment/AttachmentDropzone.tsx | 4 +- src/components/Attachment/AttachmentError.tsx | 21 +- .../Attachment/AttachmentFileInfo.tsx | 10 +- src/components/Attachment/constants.ts | 1 - .../MediaCreationModal/MediaCreationModal.jsx | 95 ----- .../MediaCreationModal/MediaCreationModal.tsx | 351 ++++++++++++++++++ .../components/Dropzone.tsx | 18 + .../MediaCreationModal/{index.js => index.ts} | 0 src/components/media/MediaModal.jsx | 1 + .../mediaHooks/useCreateMultipleMedia.ts | 89 +++++ src/layouts/screens/MediaCreationScreen.jsx | 1 + src/services/MediaFileService.ts | 12 + src/types/media.ts | 5 + 14 files changed, 547 insertions(+), 125 deletions(-) delete mode 100644 src/components/MediaCreationModal/MediaCreationModal.jsx create mode 100644 src/components/MediaCreationModal/MediaCreationModal.tsx create mode 100644 src/components/MediaCreationModal/components/Dropzone.tsx rename src/components/MediaCreationModal/{index.js => index.ts} (100%) create mode 100644 src/hooks/mediaHooks/useCreateMultipleMedia.ts 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/MediaCreationModal/MediaCreationModal.jsx b/src/components/MediaCreationModal/MediaCreationModal.jsx deleted file mode 100644 index 2d8a46e60..000000000 --- a/src/components/MediaCreationModal/MediaCreationModal.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { yupResolver } from "@hookform/resolvers/yup" -import { Input } from "@opengovsg/design-system-react" -import { useEffect, useRef, useState } from "react" -import { useForm, FormProvider } from "react-hook-form" - -import { - MediaSettingsSchema, - MediaSettingsModal, -} from "components/MediaSettingsModal" - -import { useListMediaFolderFiles } from "hooks/directoryHooks/useListMediaFolderFiles" - -import { useErrorToast } from "utils/toasts" -import { MEDIA_FILE_MAX_SIZE } from "utils/validators" - -import { getFileExt, getFileName } from "utils" - -// eslint-disable-next-line import/prefer-default-export -export const MediaCreationModal = ({ params, onProceed, onClose }) => { - const { mediaRoom } = params - const inputFile = useRef(null) - const errorToast = useErrorToast() - const { - data: { files }, - } = useListMediaFolderFiles(params, { - initialData: { files: [], total: 0 }, - }) - - const existingTitlesArray = files.map((item) => getFileName(item.name)) - const [fileExt, setFileExt] = useState("") - - const methods = useForm({ - mode: "onTouched", - resolver: yupResolver(MediaSettingsSchema(existingTitlesArray)), - context: { mediaRoom, isCreate: true }, - }) - - const onMediaUpload = async (event) => { - const mediaReader = new FileReader() - const media = event.target?.files[0] || "" - if (media.size > MEDIA_FILE_MAX_SIZE) { - errorToast({ - id: "create-media-size-error", - description: "File size exceeds 5MB. Please upload a different file.", - }) - } else { - mediaReader.onload = () => { - const fileName = getFileName(media.name) - setFileExt(getFileExt(media.name)) - methods.setValue("name", fileName) - methods.setValue("content", mediaReader.result) - } - mediaReader.readAsDataURL(media) - } - } - - useEffect(() => { - inputFile.current.click() - }, []) - - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - - <> - - { - return onProceed({ - data: { - ...submissionData.data, - name: `${submissionData.data.name}.${fileExt}`, - }, - }) - }} - mediaRoom={mediaRoom} - onClose={onClose} - toggleUploadInput={() => inputFile.current.click()} - isCreate - /> - - - ) -} diff --git a/src/components/MediaCreationModal/MediaCreationModal.tsx b/src/components/MediaCreationModal/MediaCreationModal.tsx new file mode 100644 index 000000000..b3725ed10 --- /dev/null +++ b/src/components/MediaCreationModal/MediaCreationModal.tsx @@ -0,0 +1,351 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + useDisclosure, + Text, + Icon, + useModalContext, + Progress, + ListItem, + UnorderedList, + VStack, + Flex, +} from "@chakra-ui/react" +import { Button, Infobox } from "@opengovsg/design-system-react" +import { useEffect, useState } from "react" +import { FileRejection } from "react-dropzone" +import { BiCheckCircle, BiSolidErrorCircle } from "react-icons/bi" +import { useParams } from "react-router-dom" + +import { Attachment } from "components/Attachment" + +import { useCreateMultipleMedia } from "hooks/mediaHooks/useCreateMultipleMedia" + +import { MediaDirectoryParams } from "types/folders" +import { MediaFolderTypes } from "types/media" +import { MEDIA_FILE_MAX_SIZE } from "utils" + +import { Dropzone } from "./components/Dropzone" + +type MediaSteps = "upload" | "progressing" | "success" | "failed" + +const IMAGE_UPLOAD_ACCEPTED_MIME_TYPES = { + "image/jpeg": [".jpg", ".jpeg"], + "image/png": [".png"], + "image/gif": [".gif"], + "image/svg+xml": [".svg"], + "image/tiff": [".tiff", ".tif"], + "image/bmp": [".bmp"], +} + +const FILE_UPLOAD_ACCEPTED_MIME_TYPES = { + "application/pdf": [".pdf"], +} + +interface MediaDropzoneProps { + fileRejections: FileRejection[] + uploadedFiles: File[] + setUploadedFiles: (files: File[]) => void + setFileRejections: (rejections: FileRejection[]) => void + isDisabled?: boolean + onUpload: (files: File[]) => Promise + mediaType: MediaFolderTypes +} + +const MediaDropzone = ({ + fileRejections, + uploadedFiles, + setUploadedFiles, + setFileRejections, + isDisabled, + onUpload, + mediaType, +}: MediaDropzoneProps) => { + const { onClose } = useModalContext() + + return ( + <> + Upload files + + + You can upload more than 1 file at once. Having too many files can + slow down the site loading time, so we recommend only uploading + necessary files to your site. + + { + setUploadedFiles([...uploadedFiles, ...curUploadedFiles]) + setFileRejections([...fileRejections, ...rejections]) + }} + value={uploadedFiles} + name="" + // NOTE: 5MB - maxSize is in bytes + maxSize={MEDIA_FILE_MAX_SIZE} + /> + + + + + + + ) +} + +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/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/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/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/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 c20586dd1..6796791aa 100644 --- a/src/types/media.ts +++ b/src/types/media.ts @@ -19,6 +19,11 @@ export interface SelectedMediaDto { sha: string } +export interface MediaCreationInfo { + content: string + newFileName: string +} + export interface MoveSelectedMediaDto { target: { directoryName: string From 2eeef22a4236ece95c3a55e00fd9e0040fdcbc4f Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Thu, 9 Nov 2023 12:26:19 +0800 Subject: [PATCH 4/6] fix(badge): flickering issue (#1657) * fix(badge): flickering issue * fix(badge): hover * fix(statusBadge): wrong colours * chore(stausBadge): rm redundant use memo --- src/components/Header/StatusBadge.tsx | 80 ++++++++++++++------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/src/components/Header/StatusBadge.tsx b/src/components/Header/StatusBadge.tsx index f118c27ac..b192050f4 100644 --- a/src/components/Header/StatusBadge.tsx +++ b/src/components/Header/StatusBadge.tsx @@ -1,30 +1,24 @@ import { Box, HStack, + Icon, Popover, + PopoverBody, PopoverContent, PopoverTrigger, Text, } from "@chakra-ui/react" -import { useFeatureIsOn } from "@growthbook/growthbook-react" import { Badge } from "@opengovsg/design-system-react" +import { useMemo } from "react" import { BiCheckCircle, BiError, BiLoader } from "react-icons/bi" -import { BsFillQuestionCircleFill } from "react-icons/bs" import { GoDotFill } from "react-icons/go" import { IconBaseProps } from "react-icons/lib" import { useParams } from "react-router-dom" -import { FEATURE_FLAGS } from "constants/featureFlags" - import { useGetStagingStatus } from "hooks/useGetStagingStatus" -import { FeatureFlags } from "types/featureFlags" import { BuildStatus } from "types/stagingBuildStatus" -interface StatusBadgeProps { - status: BuildStatus -} - interface StagingPopoverContentProps { status: BuildStatus } @@ -33,14 +27,14 @@ const StagingPopoverContent = ({ status }: StagingPopoverContentProps) => { let headingText = "" let bodyText = "" - let Icon = (props: IconBaseProps) => + let biLoaderIcon = (props: IconBaseProps) => switch (status) { case "READY": headingText = `All saved edits are on staging` bodyText = "Click on 'Open staging' to take a look." - Icon = (props: IconBaseProps) => ( + biLoaderIcon = (props: IconBaseProps) => ( ) @@ -49,7 +43,7 @@ const StagingPopoverContent = ({ status }: StagingPopoverContentProps) => { headingText = `Staging site is building` bodyText = "We detected a change in your site. We'll let you know when the staging site is ready." - Icon = (props: IconBaseProps) => ( + biLoaderIcon = (props: IconBaseProps) => ( ) break @@ -59,16 +53,16 @@ const StagingPopoverContent = ({ status }: StagingPopoverContentProps) => { bodyText = "Don't worry, your production site isn't affected. Try saving your page again. If the issue persists, please contact support@isomer.gov.sg." - Icon = (props: IconBaseProps) => + biLoaderIcon = (props: IconBaseProps) => break default: break } return ( - + - + @@ -82,60 +76,68 @@ const StagingPopoverContent = ({ status }: StagingPopoverContentProps) => { ) } -export const StatusBadge = (): JSX.Element => { - const { siteName } = useParams<{ siteName: string }>() - const { - data: getStagingStatusData, - isLoading: isGetStagingStatusLoading, - } = useGetStagingStatus(siteName) - const { status } = getStagingStatusData || {} - if (!status || isGetStagingStatusLoading) { - return <> - } +export const StatusBadgeComponent = ({ + status, +}: { + status: BuildStatus | undefined +}): JSX.Element => { let displayText = "" let colourScheme = "" - let dotColor = "#505660" + switch (status) { case "PENDING": displayText = "Updating staging site" colourScheme = "grey" - dotColor = "#505660" + break case "READY": displayText = "Staging site is ready" colourScheme = "success" - dotColor = "#0F796F" + break case "ERROR": displayText = "Staging site is out of date" colourScheme = "warning" - dotColor = "#FFDA68" + break default: break } + if (!status) { + return <> + } return ( - - - + + + - + + {displayText} - - - + + + + + - - - + + + ) } +export const StatusBadge = (): JSX.Element => { + const { siteName } = useParams<{ siteName: string }>() + const { data: getStagingStatusData } = useGetStagingStatus(siteName) + const status = getStagingStatusData?.status + + return +} From 478e19ebc92b3e4c6cc9a47dfdbb5000fc0baf51 Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Thu, 9 Nov 2023 12:59:39 +0800 Subject: [PATCH 5/6] fix(menubar): remove uuid (#1659) --- src/layouts/components/Editor/components/MenuBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" ? ( { ) : ( )} - + ))} ) From 14678e12a8ef1ed6ed5a5ec83ef8608307eb9100 Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Thu, 9 Nov 2023 13:56:01 +0800 Subject: [PATCH 6/6] 0.57.0 --- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) 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"