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 (
+ }>
+
+
+
+
+ )
+}
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 = () => {
-
-
+
+
>
)
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,