diff --git a/apps/studio/src/pages/sites/[siteId]/admin.tsx b/apps/studio/src/pages/sites/[siteId]/admin.tsx new file mode 100644 index 0000000000..5ce966fd30 --- /dev/null +++ b/apps/studio/src/pages/sites/[siteId]/admin.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from "react" +import { useRouter } from "next/router" +import { + Button, + Center, + chakra, + FormControl, + HStack, + Text, + VStack, +} from "@chakra-ui/react" +import { + FormErrorMessage, + Infobox, + Textarea, + useToast, +} from "@opengovsg/design-system-react" +import { ResourceType } from "~prisma/generated/generatedEnums" +import { z } from "zod" + +import { PermissionsBoundary } from "~/components/AuthWrappers" +import { BRIEF_TOAST_SETTINGS } from "~/constants/toast" +import { UnsavedSettingModal } from "~/features/editing-experience/components/UnsavedSettingModal" +import { useIsUserIsomerAdmin } from "~/hooks/useIsUserIsomerAdmin" +import { useQueryParse } from "~/hooks/useQueryParse" +import { useZodForm } from "~/lib/form" +import { type NextPageWithLayout } from "~/lib/types" +import { setSiteConfigByAdminSchema } from "~/schemas/site" +import { AdminSidebarOnlyLayout } from "~/templates/layouts/AdminSidebarOnlyLayout" +import { trpc } from "~/utils/trpc" + +const siteAdminSchema = z.object({ + siteId: z.coerce.number(), +}) + +const SUPPORTED_SITE_CONFIG_TYPES = [ + "config", + "theme", + "navbar", + "footer", +] as const + +const SiteAdminPage: NextPageWithLayout = () => { + const toast = useToast() + const router = useRouter() + const trpcUtils = trpc.useUtils() + const { siteId } = useQueryParse(siteAdminSchema) + const isUserIsomerAdmin = useIsUserIsomerAdmin() + + if (!isUserIsomerAdmin) { + toast({ + title: "You do not have permission to access this page.", + status: "error", + ...BRIEF_TOAST_SETTINGS, + }) + void router.push(`/sites/${siteId}`) + } + + const [previousConfig] = trpc.site.getConfig.useSuspenseQuery({ + id: siteId, + }) + const [previousTheme] = trpc.site.getTheme.useSuspenseQuery({ + id: siteId, + }) + const [previousNavbar] = trpc.site.getNavbar.useSuspenseQuery({ + id: siteId, + }) + const [previousFooter] = trpc.site.getFooter.useSuspenseQuery({ + id: siteId, + }) + + // NOTE: Refining the setSiteConfigByAdminSchema here instead of in site.ts since omit does not work after refine + const { + reset, + watch, + register, + handleSubmit, + formState: { isDirty, errors }, + } = useZodForm({ + schema: setSiteConfigByAdminSchema + .omit({ siteId: true }) + .refine((data) => !!data.config, { + message: "Site config must be present", + path: ["config"], + }) + .refine((data) => !!data.theme, { + message: "Site theme must be present", + path: ["theme"], + }) + .refine((data) => !!data.navbar, { + message: "Site navbar must be present", + path: ["navbar"], + }) + .refine((data) => !!data.footer, { + message: "Site footer must be present", + path: ["footer"], + }), + defaultValues: { + config: JSON.stringify(previousConfig, null, 2), + theme: JSON.stringify(previousTheme, null, 2), + navbar: JSON.stringify(previousNavbar.content, null, 2), + footer: JSON.stringify(previousFooter.content, null, 2), + }, + }) + + const { mutate, isLoading } = trpc.site.setSiteConfigByAdmin.useMutation({ + onSuccess: async () => { + await trpcUtils.site.getConfig.invalidate({ id: siteId }) + await trpcUtils.site.getTheme.invalidate({ id: siteId }) + await trpcUtils.site.getNavbar.invalidate({ id: siteId }) + await trpcUtils.site.getFooter.invalidate({ id: siteId }) + // Reset the form's isDirty but use the latest values provided by the user + reset(watch()) + toast({ + title: "Saved site config!", + description: "Check your site in 5-10 minutes to view it live.", + status: "success", + ...BRIEF_TOAST_SETTINGS, + }) + }, + onError: () => { + toast({ + title: "Error saving site config!", + description: + "If this persists, please report this issue at support@isomer.gov.sg", + status: "error", + ...BRIEF_TOAST_SETTINGS, + }) + }, + }) + + const [nextUrl, setNextUrl] = useState("") + const isOpen = !!nextUrl + + useEffect(() => { + const handleRouteChange = (url: string) => { + if (isDirty) { + router.events.off("routeChangeStart", handleRouteChange) + setNextUrl(url) + router.events.emit("routeChangeError") + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw "Error to abort router route change. Ignore this!" + } + } + + if (!isOpen) { + router.events.on("routeChangeStart", handleRouteChange) + } + return () => { + router.events.off("routeChangeStart", handleRouteChange) + } + }, [isOpen, router.events, isDirty]) + + const onClickUpdate = handleSubmit((input) => { + mutate({ + siteId, + ...input, + }) + }) + + return ( + <> + setNextUrl("")} + nextUrl={nextUrl} + /> + +
+ + + Manage site configurations + + + No validation is done on the JSON input. Please ensure that they + are valid before saving. + + + {SUPPORTED_SITE_CONFIG_TYPES.map((type) => ( + + + + Site {type} + + +