diff --git a/apps/studio/src/features/dashboard/components/FolderSettingsModal/FolderSettingsModal.tsx b/apps/studio/src/features/dashboard/components/FolderSettingsModal/FolderSettingsModal.tsx new file mode 100644 index 000000000..3bf41565a --- /dev/null +++ b/apps/studio/src/features/dashboard/components/FolderSettingsModal/FolderSettingsModal.tsx @@ -0,0 +1,194 @@ +import { Suspense, useEffect } from "react" +import { + Box, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Icon, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Skeleton, + VStack, +} from "@chakra-ui/react" +import { Button, useToast } from "@opengovsg/design-system-react" +import { BiLink } from "react-icons/bi" + +import { generateResourceUrl } from "~/features/editing-experience/components/utils" +import { useZodForm } from "~/lib/form" +import { + baseEditFolderSchema, + MAX_FOLDER_PERMALINK_LENGTH, + MAX_FOLDER_TITLE_LENGTH, +} from "~/schemas/folder" +import { trpc } from "~/utils/trpc" + +interface FolderSettingsModalProps { + isOpen: boolean + onClose: () => void + siteId: string + resourceId: number +} +export const FolderSettingsModal = ({ + isOpen, + onClose, + siteId, + resourceId, +}: FolderSettingsModalProps) => { + return ( + + + + + ) +} + +const SuspendableModalContent = ({ + isOpen, + onClose, + siteId, + resourceId, +}: FolderSettingsModalProps) => { + const [{ title: originalTitle, permalink: originalPermalink, parentId }] = + trpc.folder.readFolder.useSuspenseQuery({ + siteId: parseInt(siteId), + resourceId, + }) + const { setValue, register, handleSubmit, watch, formState, getFieldState } = + useZodForm({ + defaultValues: { + title: originalTitle, + permalink: originalPermalink, + }, + schema: baseEditFolderSchema.omit({ siteId: true, resourceId: true }), + }) + const { errors, isValid } = formState + const utils = trpc.useUtils() + const toast = useToast() + const { mutate, isLoading } = trpc.folder.editFolder.useMutation({ + onSettled: onClose, + onSuccess: async () => { + await utils.site.list.invalidate() + await utils.resource.list.invalidate() + await utils.resource.getChildrenOf.invalidate({ + resourceId: parentId ? String(parentId) : null, + }) + await utils.folder.readFolder.invalidate() + toast({ title: "Folder created!", status: "success" }) + }, + onError: (err) => { + toast({ + title: "Failed to create folder", + status: "error", + // TODO: check if this property is correct + description: err.message, + }) + }, + }) + + const onSubmit = handleSubmit((data) => { + mutate({ ...data, resourceId: String(resourceId), siteId }) + }) + + const [title, permalink] = watch(["title", "permalink"]) + + useEffect(() => { + const permalinkFieldState = getFieldState("permalink") + // This allows the syncing to happen only when the page title is not dirty + // Dirty means user has changed the value AND the value is not the same as the default value of "". + // Once the value has been cleared, dirty state will reset. + if (!permalinkFieldState.isDirty) { + setValue("permalink", generateResourceUrl(title || ""), { + shouldValidate: !!title, + }) + } + }, [getFieldState, setValue, title]) + + return ( + }> + +
+ Edit "{originalTitle}" + + + + + + Folder name + + This will be the title of the index page of your folder. + + + + + {errors.title?.message ? ( + {errors.title.message} + ) : ( + + {MAX_FOLDER_TITLE_LENGTH - (title || "").length} characters + left + + )} + + + + Folder URL + + This will be applied to every child under this folder. + + + + {errors.permalink?.message && ( + + {errors.permalink.message} + + )} + + + + {permalink} + + + + {MAX_FOLDER_PERMALINK_LENGTH - (permalink || "").length}{" "} + characters left + + + + + + + + + + +
+
+ ) +} diff --git a/apps/studio/src/features/dashboard/components/FolderSettingsModal/index.ts b/apps/studio/src/features/dashboard/components/FolderSettingsModal/index.ts new file mode 100644 index 000000000..9f0bca65a --- /dev/null +++ b/apps/studio/src/features/dashboard/components/FolderSettingsModal/index.ts @@ -0,0 +1 @@ +export * from "./FolderSettingsModal" diff --git a/apps/studio/src/features/editing-experience/components/CreateFolderModal/CreateFolderModal.tsx b/apps/studio/src/features/editing-experience/components/CreateFolderModal/CreateFolderModal.tsx index fffb1b30e..8cb9ae24b 100644 --- a/apps/studio/src/features/editing-experience/components/CreateFolderModal/CreateFolderModal.tsx +++ b/apps/studio/src/features/editing-experience/components/CreateFolderModal/CreateFolderModal.tsx @@ -58,9 +58,9 @@ export const CreateFolderModal = ({ const toast = useToast() const { mutate, isLoading } = trpc.folder.create.useMutation({ onSettled: onClose, - onSuccess: () => { - void utils.site.list.invalidate() - void utils.resource.list.invalidate() + onSuccess: async () => { + await utils.site.list.invalidate() + await utils.resource.list.invalidate() toast({ title: "Folder created!", status: "success" }) }, onError: (err) => { diff --git a/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx b/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx index 0804d6651..69d0debc1 100644 --- a/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx +++ b/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx @@ -79,9 +79,9 @@ const MoveResourceContent = withSuspense( // TODO: actually close the modal setMovedItem(null) }, - onSuccess: () => { - void utils.page.readPageAndBlob.invalidate() - void utils.resource.list.invalidate({ + onSuccess: async () => { + await utils.page.readPageAndBlob.invalidate() + await utils.resource.list.invalidate({ // TODO: Update backend `list` to use the proper schema resourceId: movedItem?.resourceId ? Number(movedItem.resourceId) @@ -89,7 +89,7 @@ const MoveResourceContent = withSuspense( }) // NOTE: We might want to have smarter logic here // and invalidate the new + old folders - void utils.folder.readFolder.invalidate() + await utils.folder.readFolder.invalidate() toast({ title: "Resource moved!" }) }, }) diff --git a/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx b/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx index 7e97fe41d..090a15e23 100644 --- a/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx +++ b/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx @@ -13,11 +13,11 @@ import { VStack, } from "@chakra-ui/react" import { Breadcrumb, Button, Menu } from "@opengovsg/design-system-react" -import { uniqueId } from "lodash" import { BiData, BiFileBlank, BiFolder } from "react-icons/bi" import { z } from "zod" import { MenuItem } from "~/components/Menu" +import { FolderSettingsModal } from "~/features/dashboard/components/FolderSettingsModal" import { ResourceTable } from "~/features/dashboard/components/ResourceTable" import { CreateFolderModal } from "~/features/editing-experience/components/CreateFolderModal" import { CreatePageModal } from "~/features/editing-experience/components/CreatePageModal" @@ -27,8 +27,8 @@ import { AdminCmsSidebarLayout } from "~/templates/layouts/AdminCmsSidebarLayout import { trpc } from "~/utils/trpc" const folderPageSchema = z.object({ - siteId: z.coerce.number(), - folderId: z.coerce.number(), + siteId: z.string(), + folderId: z.string(), }) const FolderPage: NextPageWithLayout = () => { @@ -42,14 +42,18 @@ const FolderPage: NextPageWithLayout = () => { onOpen: onFolderCreateModalOpen, onClose: onFolderCreateModalClose, } = useDisclosure() + const { + isOpen: isFolderSettingsModalOpen, + onOpen: onFolderSettingsModalOpen, + onClose: onFolderSettingsModalClose, + } = useDisclosure() const { folderId, siteId } = useQueryParse(folderPageSchema) - const [{ title, permalink, children, parentId }] = - trpc.folder.readFolder.useSuspenseQuery({ - siteId, - resourceId: folderId, - }) + const [{ title, permalink }] = trpc.folder.readFolder.useSuspenseQuery({ + siteId: parseInt(siteId), + resourceId: parseInt(folderId), + }) return ( <> @@ -78,7 +82,11 @@ const FolderPage: NextPageWithLayout = () => { - @@ -109,19 +117,27 @@ const FolderPage: NextPageWithLayout = () => { - + + ) diff --git a/apps/studio/src/schemas/folder.ts b/apps/studio/src/schemas/folder.ts index e190dc70d..2a9ea1011 100644 --- a/apps/studio/src/schemas/folder.ts +++ b/apps/studio/src/schemas/folder.ts @@ -26,3 +26,27 @@ export const readFolderSchema = z.object({ siteId: z.number().min(1), resourceId: z.number().min(1), }) + +export const baseEditFolderSchema = z.object({ + resourceId: z.string(), + permalink: z.optional(z.string()), + title: z.optional(z.string()), + siteId: z.string(), +}) + +export const editFolderSchema = baseEditFolderSchema.superRefine( + ({ permalink, title }, ctx) => { + if (!permalink && !title) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["siteId"], + message: "Either permalink or title must be provided.", + }) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["title"], + message: "Either permalink or title must be provided.", + }) + } + }, +) diff --git a/apps/studio/src/server/modules/folder/folder.router.ts b/apps/studio/src/server/modules/folder/folder.router.ts index 71d3affb9..b63f4140d 100644 --- a/apps/studio/src/server/modules/folder/folder.router.ts +++ b/apps/studio/src/server/modules/folder/folder.router.ts @@ -1,24 +1,27 @@ -import { createFolderSchema, readFolderSchema } from "~/schemas/folder" +import { + createFolderSchema, + editFolderSchema, + readFolderSchema, +} from "~/schemas/folder" import { protectedProcedure, router } from "~/server/trpc" import { db } from "../database" +import { defaultFolderSelect } from "./folder.select" export const folderRouter = router({ create: protectedProcedure .input(createFolderSchema) - .mutation( - async ({ ctx, input: { folderTitle, parentFolderId, ...rest } }) => { - const folder = await db - .insertInto("Resource") - .values({ - ...rest, - type: "Folder", - title: folderTitle, - parentId: parentFolderId ? String(parentFolderId) : null, - }) - .executeTakeFirstOrThrow() - return { folderId: folder.insertId } - }, - ), + .mutation(async ({ input: { folderTitle, parentFolderId, ...rest } }) => { + const folder = await db + .insertInto("Resource") + .values({ + ...rest, + type: "Folder", + title: folderTitle, + parentId: parentFolderId ? String(parentFolderId) : null, + }) + .executeTakeFirstOrThrow() + return { folderId: folder.insertId } + }), readFolder: protectedProcedure .input(readFolderSchema) .query(async ({ ctx, input }) => { @@ -64,4 +67,19 @@ export const folderRouter = router({ parentId, } }), + editFolder: protectedProcedure + .input(editFolderSchema) + .mutation(async ({ input: { resourceId, permalink, title, siteId } }) => { + return db + .updateTable("Resource") + .where("Resource.id", "=", resourceId) + .where("Resource.siteId", "=", Number(siteId)) + .where("Resource.type", "=", "Folder") + .set({ + permalink, + title, + }) + .returning(defaultFolderSelect) + .execute() + }), }) diff --git a/apps/studio/src/server/modules/folder/folder.select.ts b/apps/studio/src/server/modules/folder/folder.select.ts new file mode 100644 index 000000000..0361c4d91 --- /dev/null +++ b/apps/studio/src/server/modules/folder/folder.select.ts @@ -0,0 +1,13 @@ +import { DB } from "~prisma/generated/generatedTypes" + +type ResourceProperties = keyof DB["Resource"] +export const defaultFolderSelect: readonly ResourceProperties[] = [ + "id", + "parentId", + "permalink", + "title", + "siteId", + "state", + "type", + "draftBlobId", +] as const diff --git a/apps/studio/src/server/modules/page/page.router.ts b/apps/studio/src/server/modules/page/page.router.ts index 2124babca..1d0713c77 100644 --- a/apps/studio/src/server/modules/page/page.router.ts +++ b/apps/studio/src/server/modules/page/page.router.ts @@ -3,7 +3,6 @@ import { schema } from "@opengovsg/isomer-components" import { TRPCError } from "@trpc/server" import Ajv from "ajv" import isEqual from "lodash/isEqual" -import z from "zod" import { createPageSchema,