diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f663310c..6eeacf2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,26 @@ 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.58.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.57.0...v0.58.0) + +- fix(markdown): update styling to remove overflow [`#1671`](https://github.com/isomerpages/isomercms-frontend/pull/1671) +- feat/addReportingBUtton [`#1674`](https://github.com/isomerpages/isomercms-frontend/pull/1674) +- feat(media): add announcements and feature tour for media enhancements [`#1632`](https://github.com/isomerpages/isomercms-frontend/pull/1632) +- feat(media): allow create album with selected media [`#1666`](https://github.com/isomerpages/isomercms-frontend/pull/1666) +- Fix/buildStatusBadgeAPI [`#1673`](https://github.com/isomerpages/isomercms-frontend/pull/1673) +- fix(preview): use site colours for headings in preview [`#1663`](https://github.com/isomerpages/isomercms-frontend/pull/1663) +- chore: update select media modal [`#1627`](https://github.com/isomerpages/isomercms-frontend/pull/1627) +- fix(image): search pagination [`#1668`](https://github.com/isomerpages/isomercms-frontend/pull/1668) +- fix(editor): change inner prosemirror stuff to have 100% height [`#1670`](https://github.com/isomerpages/isomercms-frontend/pull/1670) +- chore(login): automatically focus on input field [`#1664`](https://github.com/isomerpages/isomercms-frontend/pull/1664) +- refactor(editpage): add alt text modal [`#1665`](https://github.com/isomerpages/isomercms-frontend/pull/1665) +- refactor(tiptap-editor): update width [`#1667`](https://github.com/isomerpages/isomercms-frontend/pull/1667) +- 0.57.0 [`#1661`](https://github.com/isomerpages/isomercms-frontend/pull/1661) + #### [v0.57.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.56.0...v0.57.0) +> 9 November 2023 + - 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) diff --git a/package-lock.json b/package-lock.json index aa651b21a..9d4768f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isomercms-frontend", - "version": "0.57.0", + "version": "0.58.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isomercms-frontend", - "version": "0.57.0", + "version": "0.58.0", "hasInstallScript": true, "dependencies": { "@braintree/sanitize-url": "^6.0.1", @@ -25,7 +25,7 @@ "@growthbook/growthbook-react": "^0.17.0", "@hello-pangea/dnd": "^16.3.0", "@hookform/resolvers": "^2.8.2", - "@opengovsg/design-system-react": "^1.3.0", + "@opengovsg/design-system-react": "^1.9.0", "@sentry/react": "^7.12.1", "@sentry/tracing": "^7.12.1", "@tanstack/react-table": "^8.5.13", @@ -5467,11 +5467,12 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.22.3", - "license": "MIT", + "version": "0.26.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.2.tgz", + "integrity": "sha512-ocpz3MxYoZlgsASiVFayiTnKukR8QZDQUMqxMdF0YFLbu8lw/IL6AHKLROI8SOpp6CxpUGPh9Q4a03eBAVEZNQ==", "dependencies": { - "@floating-ui/react-dom": "^1.3.0", - "aria-hidden": "^1.1.3", + "@floating-ui/react-dom": "^2.0.3", + "@floating-ui/utils": "^0.1.5", "tabbable": "^6.0.1" }, "peerDependencies": { @@ -5480,10 +5481,11 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "1.3.0", - "license": "MIT", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", + "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", "dependencies": { - "@floating-ui/dom": "^1.2.1" + "@floating-ui/dom": "^1.5.1" }, "peerDependencies": { "react": ">=16.8.0", @@ -5491,8 +5493,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.1.1", - "license": "MIT" + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.0.5", @@ -7102,11 +7105,12 @@ "license": "MIT" }, "node_modules/@opengovsg/design-system-react": { - "version": "1.4.0", - "license": "SEE LICENSE IN LICENSE.md", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opengovsg/design-system-react/-/design-system-react-1.9.0.tgz", + "integrity": "sha512-gMkQfUDfzyKdTqy6fJz16wY/ozC6ghblG2/aw3CPjJ+b2JGP2Siy1KCqllN2VZoqpBxRcydO7iuSwp3pzYK8QQ==", "dependencies": { "@chakra-ui/utils": "^2.0.12", - "@floating-ui/react": "^0.22.2", + "@floating-ui/react": "^0.26.1", "@fontsource/ibm-plex-mono": "^5.0.3", "country-flag-icons": "^1.4.19", "date-fns": "^2.28.0", @@ -7116,8 +7120,8 @@ "inter-ui": "^3.19.3", "libphonenumber-js": "^1.9.44", "lodash": "^4.17.21", - "nanoid": "^3.3.4", - "react-dropzone": "^11.5.1", + "nanoid": "^5.0.2", + "react-dropzone": "^14.2.3", "react-input-mask": "^3.0.0-alpha.2", "react-roving-tabindex": "^3.2.0", "react-textarea-autosize": "^8.3.3", @@ -7152,31 +7156,21 @@ "react": ">=16.12.0" } }, - "node_modules/@opengovsg/design-system-react/node_modules/file-selector": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.4.0.tgz", - "integrity": "sha512-iACCiXeMYOvZqlF1kTiYINzgepRBymz1wwjiuup9u9nayhb6g4fSwiyJ/6adli+EPwrWtpgQAh2PoS7HukEGEg==", - "dependencies": { - "tslib": "^2.0.3" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@opengovsg/design-system-react/node_modules/react-dropzone": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-11.7.1.tgz", - "integrity": "sha512-zxCMwhfPy1olUEbw3FLNPLhAm/HnaYH5aELIEglRbqabizKAdHs0h+WuyOpmA+v1JXn0++fpQDdNfUagWt5hJQ==", - "dependencies": { - "attr-accept": "^2.2.2", - "file-selector": "^0.4.0", - "prop-types": "^15.8.1" + "node_modules/@opengovsg/design-system-react/node_modules/nanoid": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz", + "integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" }, "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "react": ">= 16.8" + "node": "^18 || >=20" } }, "node_modules/@opengovsg/design-system-react/node_modules/react-input-mask": { @@ -36420,7 +36414,8 @@ }, "node_modules/tabbable": { "version": "6.2.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" }, "node_modules/table": { "version": "6.8.1", diff --git a/package.json b/package.json index 9c602488b..046dd4543 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isomercms-frontend", - "version": "0.57.0", + "version": "0.58.0", "private": true, "engines": { "node": ">=16.0.0" @@ -22,7 +22,7 @@ "@growthbook/growthbook-react": "^0.17.0", "@hello-pangea/dnd": "^16.3.0", "@hookform/resolvers": "^2.8.2", - "@opengovsg/design-system-react": "^1.3.0", + "@opengovsg/design-system-react": "^1.9.0", "@sentry/react": "^7.12.1", "@sentry/tracing": "^7.12.1", "@tanstack/react-table": "^8.5.13", diff --git a/src/assets/images/BulkUploadAnnouncementImage.tsx b/src/assets/images/BulkUploadAnnouncementImage.tsx new file mode 100644 index 000000000..5dba66dfc --- /dev/null +++ b/src/assets/images/BulkUploadAnnouncementImage.tsx @@ -0,0 +1,507 @@ +export const BulkUploadAnnouncementImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 8781b7240..30ca9f72a 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -25,3 +25,4 @@ export * from "./HomepageNewFeatures" export * from "./SingpassLogo" export * from "./EmptyAlbumImage" export * from "./EmptyDirectoryImage" +export * from "./BulkUploadAnnouncementImage" diff --git a/src/components/Attachment/AttachmentDropzone.tsx b/src/components/Attachment/AttachmentDropzone.tsx index 1a06034e5..0811942e4 100644 --- a/src/components/Attachment/AttachmentDropzone.tsx +++ b/src/components/Attachment/AttachmentDropzone.tsx @@ -27,7 +27,7 @@ export const AttachmentDropzone = ({ + {" "} or drag and drop here diff --git a/src/components/CreateMediaFolderModal/CreateMediaFolderModal.stories.tsx b/src/components/CreateMediaFolderModal/CreateMediaFolderModal.stories.tsx new file mode 100644 index 000000000..9ed7964d8 --- /dev/null +++ b/src/components/CreateMediaFolderModal/CreateMediaFolderModal.stories.tsx @@ -0,0 +1,96 @@ +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_ITEM_DATA, MOCK_MEDIA_ITEM_ONE } from "mocks/constants" +import { handlers } from "mocks/handlers" +import { buildMediaFileData, buildMediaFolderFilesData } from "mocks/utils" +import { useSuccessToast } from "utils" + +import { CreateMediaFolderModal } from "./CreateMediaFolderModal" + +const createMediaFolderModalMeta = { + title: "Components/Create Media Folder Modal", + component: CreateMediaFolderModal, + decorators: [ + (Story) => { + return ( + + + + + + ) + }, + ], +} as Meta + +const createMediaFolderModalTemplate: StoryFn< + typeof CreateMediaFolderModal +> = ({ originalSelectedMedia }) => { + const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: true }) + const successToast = useSuccessToast() + const onProceed = async (result: any) => { + console.log(result) + successToast({ + id: "storybook-create-media-folder-success", + description: "STORYBOOK: Media folder has been successfully created", + }) + onClose() + } + + return ( + <> + + + + ) +} + +export const Default = createMediaFolderModalTemplate.bind({}) +Default.args = { + originalSelectedMedia: [], +} +Default.parameters = { + msw: { + handlers: [ + ...handlers, + buildMediaFolderFilesData(MOCK_MEDIA_ITEM_DATA), + buildMediaFileData(MOCK_MEDIA_ITEM_ONE), + ], + }, +} + +export const OneSelected = createMediaFolderModalTemplate.bind({}) +OneSelected.args = { + originalSelectedMedia: [ + { filePath: "/images/hero-banner.png", size: 1234, sha: "sha1234" }, + ], +} + +export const MultipleSelected = createMediaFolderModalTemplate.bind({}) +MultipleSelected.args = { + originalSelectedMedia: [ + { filePath: "/images/hero-banner.png", size: 1234, sha: "sha1234" }, + { filePath: "/images/hero-banner2.png", size: 2345, sha: "sha1234" }, + ], +} + +export default createMediaFolderModalMeta diff --git a/src/components/CreateMediaFolderModal/CreateMediaFolderModal.tsx b/src/components/CreateMediaFolderModal/CreateMediaFolderModal.tsx new file mode 100644 index 000000000..8e756ce3b --- /dev/null +++ b/src/components/CreateMediaFolderModal/CreateMediaFolderModal.tsx @@ -0,0 +1,373 @@ +import { + Box, + Center, + FormControl, + HStack, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + SimpleGrid, + Skeleton, + Text, +} from "@chakra-ui/react" +import { yupResolver } from "@hookform/resolvers/yup" +import { + Button, + FormErrorMessage, + FormLabel, + Input, + ModalCloseButton, + Pagination, +} from "@opengovsg/design-system-react" +import { AxiosError } from "axios" +import _ from "lodash" +import { useEffect } from "react" +import { FormProvider, useForm } from "react-hook-form" +import { UseMutateAsyncFunction } from "react-query" +import { useRouteMatch } from "react-router-dom" + +import { DirectorySettingsSchema } from "components/DirectorySettingsModal" +import { ImagePreviewCard } from "components/ImagePreviewCard" + +import { MEDIA_PAGINATION_SIZE } from "constants/media" + +import { useListMediaFolderFiles } from "hooks/directoryHooks" +import { useGetAllMediaFiles } from "hooks/directoryHooks/useGetAllMediaFiles" +import { usePaginate } from "hooks/usePaginate" + +import { FilePreviewCard } from "layouts/Media/components" + +import { getSelectedMediaDto } from "utils/media" +import { isWriteActionsDisabled } from "utils/reviewRequests" + +import { GetMediaSubdirectoriesDto, MediaData } from "types/directory" +import { MiddlewareError } from "types/error" +import { + MediaFolderCreationInfo, + MediaFolderTypes, + MediaLabels, + SelectedMediaDto, +} from "types/media" + +interface CreateMediaFolderModalProps { + originalSelectedMedia: SelectedMediaDto[] + mediaLabels: MediaLabels + mediaType: MediaFolderTypes + subDirectories: GetMediaSubdirectoriesDto | undefined + mediaDirectoryName: string + isOpen: boolean + isLoading: boolean + onClose: () => void + onProceed: UseMutateAsyncFunction< + void, + AxiosError, + MediaFolderCreationInfo, + unknown + > +} + +const getButtonLabel = ( + originalSelectedMedia: SelectedMediaDto[], + selectedMedia: SelectedMediaDto[], + mediaLabels: MediaLabels +) => { + const { + singularDirectoryLabel, + singularMediaLabel, + pluralMediaLabel, + } = mediaLabels + + if (originalSelectedMedia.length > 0) { + return `Create ${singularDirectoryLabel}` + } + + if (selectedMedia.length > 1) { + return `Add ${selectedMedia.length} ${pluralMediaLabel} to ${singularDirectoryLabel}` + } + + if (selectedMedia.length === 1) { + return `Add ${singularMediaLabel} to ${singularDirectoryLabel}` + } + + return `Add ${pluralMediaLabel} to ${singularDirectoryLabel}` +} + +export const CreateMediaFolderModal = ({ + originalSelectedMedia, + mediaLabels, + mediaType, + subDirectories, + mediaDirectoryName, + isOpen, + isLoading, + onClose, + onProceed, +}: CreateMediaFolderModalProps): JSX.Element => { + const { params } = useRouteMatch<{ + siteName: string + }>() + const { siteName } = params + const [curPage, setCurPage] = usePaginate() + const isWriteDisabled = isWriteActionsDisabled(siteName) + const { + singularMediaLabel, + pluralMediaLabel, + singularDirectoryLabel, + } = mediaLabels + + const existingTitles = subDirectories?.directories.map( + (directory) => directory.name + ) + + const { + data: mediaFolderFiles, + isLoading: isListMediaFilesLoading, + } = useListMediaFolderFiles({ + siteName, + mediaDirectoryName, + // NOTE: Subtracting 1 here because `usePaginate` + // returns an index with 1 offset + curPage: curPage - 1, + limit: MEDIA_PAGINATION_SIZE, + }) + + const files = useGetAllMediaFiles( + mediaFolderFiles?.files || [], + siteName, + mediaDirectoryName + ) + + const methods = useForm({ + mode: "onTouched", + resolver: yupResolver(DirectorySettingsSchema(existingTitles)), + context: { + type: "mediaDirectoryName", + }, + defaultValues: { + newDirectoryName: "", + selectedPages: originalSelectedMedia, + }, + }) + + const handleSelect = (fileData: MediaData) => { + if ( + methods + .getValues("selectedPages") + .some((selectedData) => selectedData.filePath === fileData.mediaPath) + ) { + methods.setValue( + "selectedPages", + methods + .getValues("selectedPages") + .filter( + (selectedData) => selectedData.filePath !== fileData.mediaPath + ) + ) + } else { + const selectedData = getSelectedMediaDto(fileData) + methods.setValue("selectedPages", [ + ...methods.getValues("selectedPages"), + selectedData, + ]) + } + } + + useEffect(() => { + methods.setValue("selectedPages", originalSelectedMedia) + }, [methods, originalSelectedMedia]) + + const onModalClose = () => { + methods.reset() + onClose() + } + + const onSubmit = async (data: MediaFolderCreationInfo) => { + await onProceed(data) + methods.reset() + } + + return ( + + + +
+ + + + + + Create a new {singularDirectoryLabel} + {originalSelectedMedia.length > 0 + ? ` with ${originalSelectedMedia.length} ${ + originalSelectedMedia.length === 1 + ? singularMediaLabel + : pluralMediaLabel + }` + : ""} + + + + + + + {_.upperFirst(singularDirectoryLabel)} name + + + + {methods.formState.errors.newDirectoryName?.message} + + + + {originalSelectedMedia.length === 0 && ( + <> + + Select {pluralMediaLabel} to add to this{" "} + {singularDirectoryLabel} + + + + Click ‘Skip, I’ll add {pluralMediaLabel} later’ to create an + empty {singularDirectoryLabel}. You can add{" "} + {pluralMediaLabel} to the {singularDirectoryLabel} later. + + + + {files.length > 0 && ( + + + {files.map(({ data }) => { + return data && mediaType === "images" ? ( + + selectedData.filePath === data.mediaPath + )} + isMenuNeeded={false} + onClick={() => handleSelect(data)} + onCheck={() => handleSelect(data)} + /> + ) : ( + data && ( + + selectedData.filePath === data.mediaPath + )} + isMenuNeeded={false} + onClick={() => handleSelect(data)} + onCheck={() => handleSelect(data)} + /> + ) + ) + })} + + + {/* Pagination segment */} + {mediaFolderFiles?.total !== 0 && ( +
+ setCurPage(page)} + /> +
+ )} +
+ )} +
+ + )} +
+ + + + {originalSelectedMedia.length > 0 && ( + + )} + {originalSelectedMedia.length === 0 && ( + + )} + + + +
+
+
+
+ ) +} diff --git a/src/components/CreateMediaFolderModal/index.ts b/src/components/CreateMediaFolderModal/index.ts new file mode 100644 index 000000000..3bba53832 --- /dev/null +++ b/src/components/CreateMediaFolderModal/index.ts @@ -0,0 +1 @@ +export * from "./CreateMediaFolderModal" diff --git a/src/components/DeleteMediaModal/DeleteMediaModal.tsx b/src/components/DeleteMediaModal/DeleteMediaModal.tsx index f4ffd0642..320ba4c1c 100644 --- a/src/components/DeleteMediaModal/DeleteMediaModal.tsx +++ b/src/components/DeleteMediaModal/DeleteMediaModal.tsx @@ -64,7 +64,7 @@ export const DeleteMediaModal = ({ {selectedMedia.length === 1 && ( <> - + Delete {selectedMedia[0].filePath.split("/").pop()}? diff --git a/src/components/DirectorySettingsModal/DirectorySettingsSchema.jsx b/src/components/DirectorySettingsModal/DirectorySettingsSchema.jsx index b5740fc14..ffd3099e1 100644 --- a/src/components/DirectorySettingsModal/DirectorySettingsSchema.jsx +++ b/src/components/DirectorySettingsModal/DirectorySettingsSchema.jsx @@ -78,4 +78,5 @@ export const DirectorySettingsSchema = (existingTitlesArray = []) => () => false ) }), + selectedPages: Yup.array().ensure(), }) diff --git a/src/components/Header.jsx b/src/components/Header.jsx index c1565f54a..0274a1774 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,12 +1,4 @@ -import { - Box, - Flex, - Icon, - Text, - HStack, - useDisclosure, - Skeleton, -} from "@chakra-ui/react" +import { Box, Flex, Icon, Text, HStack, useDisclosure } from "@chakra-ui/react" import { Button, IconButton } from "@opengovsg/design-system-react" import axios from "axios" import PropTypes from "prop-types" @@ -19,17 +11,20 @@ import { StatusBadge } from "components/Header/StatusBadge" import { ViewStagingSiteModal } from "components/ViewStagingSiteModal" import { WarningModal } from "components/WarningModal" +import { FEATURE_FLAGS } from "constants/featureFlags" + import { useLoginContext } from "contexts/LoginContext" import { useGetReviewRequests, useGetStagingUrl, } from "hooks/siteDashboardHooks" -import { useGetStagingStatus } from "hooks/useGetStagingStatus" import useRedirectHook from "hooks/useRedirectHook" import { ReviewRequestModal } from "layouts/ReviewRequest" +import { useIsIsomerFeatureOn } from "utils/growthbook" + import { getBackButton } from "utils" // axios settings @@ -88,10 +83,9 @@ const Header = ({ if (isEditPage && !shouldAllowEditPageBackNav) onWarningModalOpen() else toggleBackNav() } - const { - data: getStagingStatusData, - isLoading: isGetStagingStatusLoading, - } = useGetStagingStatus(siteName) + const isShowStagingBuildStatusEnabled = useIsIsomerFeatureOn( + FEATURE_FLAGS.IS_SHOW_STAGING_BUILD_STATUS_ENABLED + ) return ( <> @@ -139,9 +133,13 @@ const Header = ({ ) : null} - - {getStagingStatusData && } - + ( + {isShowStagingBuildStatusEnabled && ( + + + + )} + ) - + ) @@ -112,17 +115,20 @@ const MediaDropzone = ({ interface UploadProgressIndicatorProps { cur: number total: number + mediaType: MediaFolderTypes } const UploadProgressIndicator = ({ cur, total, + mediaType, }: UploadProgressIndicatorProps) => { const { onClose } = useModalContext() + const { pluralMediaLabel } = getMediaLabels(mediaType) return ( <> - Upload files + Upload {pluralMediaLabel} {`Uploading ${cur} of ${total} files`}
+ >{`Uploading ${cur} of ${total} ${pluralMediaLabel}`} Do not close this screen or navigate away @@ -152,16 +158,26 @@ const UploadProgressIndicator = ({ interface MediaUploadSuccessDropzoneProps { numMedia: number errorMessages: string[] + mediaType: MediaFolderTypes } const MediaUploadSuccessDropzone = ({ numMedia, errorMessages, + mediaType, }: MediaUploadSuccessDropzoneProps) => { const { onClose } = useModalContext() + const { + singularMediaLabel, + pluralMediaLabel, + singularDirectoryLabel, + } = getMediaLabels(mediaType) return ( <> - Files uploaded! + + {_.upperFirst(numMedia === 1 ? singularMediaLabel : pluralMediaLabel)}{" "} + uploaded! + - {`Successfully uploaded ${numMedia} files`} + + {`Successfully uploaded ${numMedia} ${ + numMedia === 1 ? singularMediaLabel : pluralMediaLabel + }`} + {errorMessages.length > 0 && ( <> @@ -187,7 +204,7 @@ const MediaUploadSuccessDropzone = ({ mr="0.5rem" /> - {`${errorMessages.length} files failed to upload`} + {`${errorMessages.length} ${pluralMediaLabel} failed to upload`} @@ -203,7 +220,7 @@ const MediaUploadSuccessDropzone = ({ )} - + ) @@ -211,11 +228,18 @@ const MediaUploadSuccessDropzone = ({ interface MediaUploadFailedDropzoneProps { errorMessages: string[] + mediaType: MediaFolderTypes } const MediaUploadFailedDropzone = ({ errorMessages, + mediaType, }: MediaUploadFailedDropzoneProps) => { const { onClose } = useModalContext() + const { + singularMediaLabel, + pluralMediaLabel, + singularDirectoryLabel, + } = getMediaLabels(mediaType) return ( <> @@ -225,7 +249,9 @@ const MediaUploadFailedDropzone = ({ {`${errorMessages.length} ${ - errorMessages.length > 1 ? "files" : "file" + errorMessages.length === 1 + ? singularMediaLabel + : pluralMediaLabel } failed to upload`} @@ -247,7 +273,7 @@ const MediaUploadFailedDropzone = ({ - + ) @@ -334,16 +360,21 @@ export const MediaCreationModal = ({ )} {curStep === "success" && ( )} {curStep === "failed" && ( - + )} diff --git a/src/components/MoveMediaModal/MoveMediaModal.tsx b/src/components/MoveMediaModal/MoveMediaModal.tsx index 67fe270b3..afcf5b8d6 100644 --- a/src/components/MoveMediaModal/MoveMediaModal.tsx +++ b/src/components/MoveMediaModal/MoveMediaModal.tsx @@ -118,7 +118,7 @@ export const MoveMediaModal = ({ - + Move{" "} {selectedMedia.length === 1 ? getLastChildOfPath(selectedMedia[0].filePath) diff --git a/src/components/media/MediasSelectModal.jsx b/src/components/media/MediasSelectModal.jsx index ce993f0c7..df6b3f2a5 100644 --- a/src/components/media/MediasSelectModal.jsx +++ b/src/components/media/MediasSelectModal.jsx @@ -13,16 +13,21 @@ import { ModalFooter, ModalBody, ModalCloseButton, - Spacer, - Divider, + HStack, + BreadcrumbItem, + BreadcrumbLink, } from "@chakra-ui/react" -import { Button, Searchbar, Pagination } from "@opengovsg/design-system-react" -import { useState } from "react" +import { + Button, + Searchbar, + Pagination, + Breadcrumb, +} from "@opengovsg/design-system-react" +import { useState, useEffect } from "react" import { useFormContext } from "react-hook-form" -import { Link, useRouteMatch } from "react-router-dom" +import { useRouteMatch } from "react-router-dom" import { FolderCard } from "components/FolderCard" -import { BreadcrumbItem } from "components/folders/Breadcrumb" import { LoadingButton } from "components/LoadingButton" import MediaCard from "components/media/MediaCard" @@ -52,24 +57,26 @@ const MediasSelectModal = ({ setQueryParams, }) => { const { params } = useRouteMatch() - const { siteName } = params + const { siteName, fileName } = params const { mediaRoom, mediaDirectoryName } = queryParams const [curPage, setCurPage] = usePaginate() + const [mediaFolderSubdirectories, setMediaFolderSubdirectories] = useState([]) + const [mediaFolderFiles, setMediaFolderFiles] = useState([]) + const [total, setTotal] = useState() + + const [searchValue, setSearchedValue] = useState("") const { - data: { directories: mediaFolderSubdirectories }, + data: listMediaDirectoriesData, isLoading: isListMediaFolderSubdirectoriesLoading, - } = useListMediaFolderSubdirectories( - { - ...queryParams, - }, - { initialData: { directories: [] } } - ) + } = useListMediaFolderSubdirectories({ + ...queryParams, + }) const { - data: { files: mediaFolderFiles, total }, + data: listMediaFilesData, isLoading: isListMediaFilesLoading, } = useListMediaFolderFiles( { @@ -78,17 +85,29 @@ const MediasSelectModal = ({ // returns an index with 1 offset curPage: curPage - 1, limit: MEDIA_PAGINATION_SIZE, + search: searchValue, }, { initialData: { files: [] } } ) + useEffect(() => { + if (listMediaDirectoriesData) + setMediaFolderSubdirectories(listMediaDirectoriesData.directories) + }, [listMediaDirectoriesData]) + + useEffect(() => { + if (listMediaFilesData) { + setMediaFolderFiles(listMediaFilesData.files) + setTotal(listMediaFilesData.total) + } + }, [listMediaFilesData]) + const files = useGetAllMediaFiles( mediaFolderFiles || [], params.siteName, mediaDirectoryName ) - const [searchValue, setSearchedValue] = useState("") const filteredDirectories = filterMediaByFileName( mediaFolderSubdirectories, searchValue @@ -109,60 +128,67 @@ const MediasSelectModal = ({ closeOnOverlayClick={false} > - + - - {`Select ${mediaRoom.slice(0, -1)}`} - - {/* Search medias */} - - setSearchedValue(val)} /> - - {/* Upload medias */} - - + + + {`Add ${mediaRoom.slice(0, -1)}${ + fileName ? ` to ${decodeURIComponent(fileName)}` : "" + }`} + + + + Choose from your images or upload a new image. You can organise + your images in Workspace > Images. + + + {/* Search medias */} + + setSearchedValue(val)} + placeholder="Press enter to search" + /> + + {/* Upload medias */} + + + - - -
-

- For {mediaRoom} other than - {mediaRoom === "images" - ? ` 'png', 'jpg', '.jpeg', 'gif', 'tif', '.tiff', 'bmp', 'ico', 'svg'` - : ` 'pdf'`} - , please use - - https://go.gov.sg - - to upload and link them to your Isomer site. -

-
-
+ {queryParams.mediaDirectoryName ? queryParams.mediaDirectoryName.split("%2F").map((dir, idx) => ( { - e.preventDefault() - setQueryParams((prevState) => ({ - ...prevState, - mediaDirectoryName: getMediaDirectoryName( - queryParams.mediaDirectoryName, - { end: idx + 1 } - ), - })) - }} - /> + > + { + e.preventDefault() + setQueryParams((prevState) => ({ + ...prevState, + mediaDirectoryName: getMediaDirectoryName( + queryParams.mediaDirectoryName, + { end: idx + 1 } + ), + })) + }} + > + {deslugifyDirectory(dir)} + + )) : null} -
+
@@ -204,24 +230,28 @@ const MediasSelectModal = ({ isLoaded={!isListMediaFilesLoading} > - {files - .filter(({ data }) => filteredMedias.includes(data?.name)) - .map(({ data, isLoading }, mediaItemIndex) => ( - - onMediaSelect(data)} - isSelected={data.name === watch("selectedMedia")?.name} - /> - - ))} + {files && + files + .filter(({ data }) => filteredMedias.includes(data?.name)) + .map(({ data, isLoading }, mediaItemIndex) => ( + + onMediaSelect(data)} + isSelected={ + data.name === watch("selectedMedia")?.name + } + showSettings={false} + /> + + ))}
diff --git a/src/components/pages/MarkdownEditor.jsx b/src/components/pages/MarkdownEditor.jsx index 629aa6c1d..38dd11c53 100644 --- a/src/components/pages/MarkdownEditor.jsx +++ b/src/components/pages/MarkdownEditor.jsx @@ -7,6 +7,7 @@ import EditorModals from "components/pages/EditorModals" import { useMarkdown } from "hooks/useMarkdown" import editorStyles from "styles/isomer-cms/pages/Editor.module.scss" +import "./editor.scss" import { boldButton, @@ -137,6 +138,7 @@ const MarkdownEditor = ({ siteName, onChange, value, isLoading }) => { > - + {tags.map((tagVariant) => { switch (tagVariant) { diff --git a/src/features/AnnouncementModal/Announcements.ts b/src/features/AnnouncementModal/Announcements.ts index 851eae620..ac416b6c7 100644 --- a/src/features/AnnouncementModal/Announcements.ts +++ b/src/features/AnnouncementModal/Announcements.ts @@ -1,5 +1,9 @@ -import { HomepageNewFeatures, IsomerThumbsUp } from "assets" -import { IsomerWaitingLine } from "assets/images/IsomerWaitingLine" +import { + HomepageNewFeatures, + IsomerThumbsUp, + IsomerWaitingLine, + BulkUploadAnnouncementImage, +} from "assets" import { AnnouncementBatch } from "types/announcements" import { AnnouncementDescription } from "./components/AnnouncementDescription" @@ -36,4 +40,16 @@ export const ANNOUNCEMENT_BATCH: AnnouncementBatch[] = [ }, ], }, + { + onCloseButtonText: "Got it", + announcements: [ + { + title: "Bulk image uploads are here!", + description: + "Now, you can upload multiple images at once. To ensure that your site runs fast, we recommend only uploading images that you need for your Isomer site.", + image: BulkUploadAnnouncementImage, + tags: ["New Feature"], + }, + ], + }, ] diff --git a/src/features/FeatureTour/FeatureTourSequence.ts b/src/features/FeatureTour/FeatureTourSequence.ts index 91ddb3d7d..89e3f9b3c 100644 --- a/src/features/FeatureTour/FeatureTourSequence.ts +++ b/src/features/FeatureTour/FeatureTourSequence.ts @@ -31,6 +31,34 @@ export const DASHBOARD_FEATURE_STEPS: Array = [ }, ] +export const MEDIA_FEATURE_STEPS: Array = [ + { + target: "#isomer-media-feature-tour-step-1", + title: "Upload more than 1 image at a time", + content: "Click ‘Upload images’ to add multiple images to your albums.", + floaterProps: { placement: "bottom-end" }, + placement: "bottom-end", + }, + { + target: "#isomer-media-feature-tour-step-2", + title: "Select multiple images and organise them", + content: "Now, you can select multiple images by clicking on them.", + floaterProps: { placement: "top-end" }, + placement: "top-end", + }, +] + +export const MEDIA_ONSELECT_FEATURE_STEPS: Array = [ + { + target: "#isomer-media-onselect-feature-tour-step-1", + title: "Bulk actions can be found here", + content: + "You can create a new folder with your selected images or move them to a different folder! Be careful when you’re deleting multiple images though.", + floaterProps: { placement: "bottom-end" }, + placement: "bottom-end", + }, +] + export const WORKSPACE_FEATURE_STEPS: Array = [ { target: "#isomer-workspace-feature-tour-step-1", diff --git a/src/hooks/directoryHooks/useCreateDirectoryAndMoveFilesHook.ts b/src/hooks/directoryHooks/useCreateDirectoryAndMoveFilesHook.ts new file mode 100644 index 000000000..3a7c2925f --- /dev/null +++ b/src/hooks/directoryHooks/useCreateDirectoryAndMoveFilesHook.ts @@ -0,0 +1,101 @@ +import { AxiosError } from "axios" +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQueryClient, +} from "react-query" + +import { + LIST_MEDIA_DIRECTORY_FILES_KEY, + GET_ALL_MEDIA_FILES_KEY, + LIST_MEDIA_FOLDERS_KEY, +} from "constants/queryKeys" + +import { moveMultipleMedia } from "hooks/moveHooks" + +import { apiService } from "services/ApiService" + +import { DirectoryService } from "services" +import { MiddlewareError } from "types/error" +import { MediaDirectoryParams } from "types/folders" +import { MediaFolderCreationInfo } from "types/media" + +const createDirectoryAndMoveFiles = async ( + params: MediaDirectoryParams, + { newDirectoryName, selectedPages }: MediaFolderCreationInfo +) => { + const directoryService = new DirectoryService({ apiClient: apiService }) + const { siteName, mediaDirectoryName } = params + + const newDirectoryPath = [ + decodeURIComponent(mediaDirectoryName), + newDirectoryName, + ].join("/") + + if (selectedPages.length === 0) { + return directoryService.create( + { siteName, mediaDirectoryName, isCreate: true }, + { newDirectoryName: newDirectoryPath, items: [] } + ) + } + + return ( + directoryService + .create( + { siteName, mediaDirectoryName, isCreate: true }, + { newDirectoryName: newDirectoryPath, items: [] } + ) + // This wait is necessary to avoid the repo lock + .then(() => new Promise((resolve) => setTimeout(resolve, 500))) + .then(() => { + moveMultipleMedia(params, { + target: { directoryName: newDirectoryPath }, + items: selectedPages, + }) + }) + // This wait is necessary to allow the backend to catch up + .then(() => new Promise((resolve) => setTimeout(resolve, 500))) + ) +} + +export const useCreateDirectoryAndMoveFilesHook = ( + params: MediaDirectoryParams, + mutationOptions?: Omit< + UseMutationOptions< + void, + AxiosError, + MediaFolderCreationInfo + >, + "mutationFn" | "mutationKey" + > +): UseMutationResult< + void, + AxiosError, + MediaFolderCreationInfo +> => { + const queryClient = useQueryClient() + + return useMutation< + void, + AxiosError, + MediaFolderCreationInfo + >((data) => createDirectoryAndMoveFiles(params, data), { + ...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?.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/hooks/moveHooks/useMoveMultipleMediaHook.tsx b/src/hooks/moveHooks/useMoveMultipleMediaHook.tsx index ffe6e3967..51aa03954 100644 --- a/src/hooks/moveHooks/useMoveMultipleMediaHook.tsx +++ b/src/hooks/moveHooks/useMoveMultipleMediaHook.tsx @@ -19,7 +19,7 @@ import { MiddlewareError } from "types/error" import { MediaDirectoryParams } from "types/folders" import { MoveMultipleMediaDto, MoveSelectedMediaDto } from "types/media" -const moveMultipleMedia = async ( +export const moveMultipleMedia = async ( { siteName }: MediaDirectoryParams, { target, items }: MoveSelectedMediaDto ) => { diff --git a/src/hooks/useGetStagingStatus.ts b/src/hooks/useGetStagingStatus.ts index 259b7893d..4e47877ea 100644 --- a/src/hooks/useGetStagingStatus.ts +++ b/src/hooks/useGetStagingStatus.ts @@ -1,7 +1,5 @@ -import { useFeatureIsOn } from "@growthbook/growthbook-react" import { useQuery, UseQueryResult } from "react-query" -import { FEATURE_FLAGS } from "constants/featureFlags" import { GET_STAGING_BUILD_STATUS_KEY } from "constants/queryKeys" import { getStagingBuildStatus } from "services/StagingBuildService" diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index 32c6cd2ce..4cf3a3827 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -139,6 +139,7 @@ export const EditPage = () => { )} {isMediaModalOpen && ( { diff --git a/src/layouts/EditPage/TiptapEditPage.tsx b/src/layouts/EditPage/TiptapEditPage.tsx index c755bfdae..0b0e95fa7 100644 --- a/src/layouts/EditPage/TiptapEditPage.tsx +++ b/src/layouts/EditPage/TiptapEditPage.tsx @@ -58,10 +58,11 @@ export const TiptapEditPage = ({ variant="tiptap" > {/* Editor */} - + {/* Preview */} diff --git a/src/layouts/Login/components/OtpForm.tsx b/src/layouts/Login/components/OtpForm.tsx index 6b45ae723..7268a08b2 100644 --- a/src/layouts/Login/components/OtpForm.tsx +++ b/src/layouts/Login/components/OtpForm.tsx @@ -64,10 +64,12 @@ export const OtpForm = ({ void } // Utility method for getting the text under the page title in the header @@ -115,27 +123,15 @@ const getPlaceholderText = ( return results.join(" ") } -// Utility method to construct a SelectedMediaDto from MediaData -const getSelectedMediaDto = (fileData: MediaData) => { - const selectedData: SelectedMediaDto = { - filePath: fileData.mediaPath, - sha: fileData.sha, - size: fileData.size || 0, - } - - return selectedData -} - const CreateDirectoryButton = ({ isWriteDisabled, directoryLevel, singularDirectoryLabel, - url, + onOpen, }: CreateDirectoryButtonProps) => ( = MAX_MEDIA_LEVELS} > {`Create ${singularDirectoryLabel}`} @@ -151,6 +147,7 @@ export const Media = (): JSX.Element => { mediaRoom: MediaFolderTypes mediaDirectoryName: string }>() + const { setRedirectToPage } = useRedirectHook() const { siteName, mediaRoom: mediaType, mediaDirectoryName } = params const [selectedMedia, setSelectedMedia] = useState([]) // Note: We need a separate variable here so that we do not lose the selection @@ -161,6 +158,12 @@ export const Media = (): JSX.Element => { setIndividualMedia, ] = useState(null) + const { + isOpen: isCreateMediaFolderModalOpen, + onOpen: onCreateMediaFolderModalOpen, + onClose: onCreateMediaFolderModalClose, + } = useDisclosure() + const { isOpen: isMoveModalOpen, onOpen: onMoveModalOpen, @@ -248,6 +251,43 @@ export const Media = (): JSX.Element => { params.mediaDirectoryName ) + const { + mutateAsync: createDirectoryAndMoveFiles, + isLoading: isCreateDirectoryAndMoveFilesLoading, + } = useCreateDirectoryAndMoveFilesHook(params, { + onSettled: () => { + setSelectedMedia([]) + onCreateMediaFolderModalClose() + }, + onSuccess: (data, variables, context) => { + if (variables.selectedPages.length === 0) { + successToast({ + id: "create-directory-success", + description: `Successfully created ${singularDirectoryLabel}!`, + }) + } else { + successToast({ + id: "create-directory-and-move-files-success", + description: `Successfully created ${singularDirectoryLabel} and moved ${ + variables.selectedPages.length === 1 + ? singularMediaLabel + : pluralMediaLabel + }!`, + }) + } + + setRedirectToPage( + `${url}%2F${encodeURIComponent(variables.newDirectoryName)}` + ) + }, + onError: (err, variables, context) => { + errorToast({ + id: "create-directory-error", + description: `Your ${singularDirectoryLabel} could not be created successfully. ${DEFAULT_RETRY_MSG}`, + }) + }, + }) + const { mutate: moveMultipleMedia, isLoading: isMoveMultipleMediaLoading, @@ -319,6 +359,18 @@ export const Media = (): JSX.Element => { return ( <> + + { ) }} /> + {(subDirCount !== 0 || filesCount !== 0) && + selectedMedia.length === 0 && ( + + )} + + {(subDirCount !== 0 || filesCount !== 0) && + selectedMedia.length !== 0 && ( + + )} @@ -386,7 +453,12 @@ export const Media = (): JSX.Element => { Deselect all - + setIndividualMedia(null)} @@ -420,17 +492,29 @@ export const Media = (): JSX.Element => { onMoveModalOpen()}> - Move images to album + Move{" "} + {selectedMedia.length === 1 + ? singularMediaLabel + : pluralMediaLabel}{" "} + to {singularDirectoryLabel} - {/* FIXME: To add back when flow is available - - - Create new album with images - */} + + {directoryLevel < MAX_MEDIA_LEVELS && ( + onCreateMediaFolderModalOpen()} + > + + Create new {singularDirectoryLabel} with{" "} + {selectedMedia.length === 1 + ? singularMediaLabel + : pluralMediaLabel} + + )} + onDeleteModalOpen()} @@ -460,7 +544,7 @@ export const Media = (): JSX.Element => { isWriteDisabled={isWriteDisabled} directoryLevel={directoryLevel} singularDirectoryLabel={singularDirectoryLabel} - url={url} + onOpen={onCreateMediaFolderModalOpen} /> ) : ( @@ -468,11 +552,16 @@ export const Media = (): JSX.Element => { isWriteDisabled={isWriteDisabled} directoryLevel={directoryLevel} singularDirectoryLabel={singularDirectoryLabel} - url={url} + onOpen={onCreateMediaFolderModalOpen} /> )} - +