Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update create page flow according to design #325

Merged
merged 18 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@chakra-ui/theme-tools": "^2.1.2",
"@chakra-ui/utils": "^2.0.15",
"@fontsource/ibm-plex-mono": "^5.0.13",
"@formkit/auto-animate": "^0.8.2",
"@hello-pangea/dnd": "^16.6.0",
"@hookform/resolvers": "^3.8.0",
"@jsonforms/core": "^3.3.0",
Expand Down
12 changes: 12 additions & 0 deletions apps/studio/src/components/NextImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Image from "next/image"
import { chakra } from "@chakra-ui/react"

/**
* Use NextJS Image component with chakra-ui
* Need to forward the props NextJS Image expects to the Image component instead of
* chakra's props.
*/
export const NextImage = chakra(Image, {
shouldForwardProp: (prop) =>
["height", "width", "quality", "src", "alt"].includes(prop),
})
4 changes: 2 additions & 2 deletions apps/studio/src/constants/layouts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export const APP_GRID_TEMPLATE_COLUMN = {
base: "repeat(4, 1fr)",
md: "repeat(10, 1fr)",
md: "repeat(12, 1fr)",
}
export const APP_GRID_COLUMN = { base: "1 / 5", md: "1 / 12", lg: "3 / 11" }
export const APP_GRID_COLUMN = { base: "1 / 5", md: "1 / 12", lg: "4 / 10" }

export const ADMIN_NAVBAR_HEIGHT = "3.5rem"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { UseDisclosureReturn } from "@chakra-ui/react"
import { Modal, ModalContent, ModalOverlay } from "@chakra-ui/react"

import { CreatePageWizardProvider } from "./CreatePageWizardContext"
import { CreatePageModalScreen } from "./ModalScreen"

interface CreatePageModalProps
extends Pick<UseDisclosureReturn, "isOpen" | "onClose"> {
siteId: number
folderId?: number
}

export const CreatePageModal = ({
isOpen,
onClose,
siteId,
folderId,
}: CreatePageModalProps): JSX.Element => {
return (
<Modal size="full" isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent height="$100vh" overflow="hidden">
<CreatePageWizardProvider
onClose={onClose}
siteId={siteId}
folderId={folderId}
key={String(isOpen)}
>
<CreatePageModalScreen />
</CreatePageWizardProvider>
</ModalContent>
</Modal>
)
}
karrui marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { UseDisclosureReturn } from "@chakra-ui/react"
import type { IsomerSchema } from "@opengovsg/isomer-components"
import type { PropsWithChildren } from "react"
import { createContext, useContext, useMemo, useState } from "react"
import { merge } from "lodash"

import articleLayoutPreview from "~/features/editing-experience/data/articleLayoutPreview.json"
import contentLayoutPreview from "~/features/editing-experience/data/contentLayoutPreview.json"
import { useZodForm } from "~/lib/form"
import { createPageSchema } from "~/schemas/page"
import { trpc } from "~/utils/trpc"

export enum CreatePageFlowStates {
Layout = "layout",
Details = "details",
}

const createPageFormSchema = createPageSchema.omit({
siteId: true,
folderId: true,
})

interface CreatePageWizardProps extends Pick<UseDisclosureReturn, "onClose"> {
siteId: number
folderId?: number
}

export type CreatePageWizardContextReturn = ReturnType<
typeof useCreatePageWizardContext
>

const CreatePageWizardContext = createContext<
CreatePageWizardContextReturn | undefined
>(undefined)

export const useCreatePageWizard = (): CreatePageWizardContextReturn => {
const context = useContext(CreatePageWizardContext)
if (!context) {
throw new Error(
`useCreatePageWizard must be used within a CreatePageWizardProvider component`,
)
}
return context
}

export const INITIAL_STEP_STATE: CreatePageFlowStates =
CreatePageFlowStates.Layout

const useCreatePageWizardContext = ({
siteId,
folderId,
onClose,
}: CreatePageWizardProps) => {
const [currentStep, setCurrentStep] =
useState<CreatePageFlowStates>(INITIAL_STEP_STATE)

const formMethods = useZodForm({
schema: createPageFormSchema,
defaultValues: {
title: "",
permalink: "",
layout: "content",
},
})

const [layout, title] = formMethods.watch(["layout", "title"])

const layoutPreviewJson: IsomerSchema = useMemo(() => {
const jsonPreview =
layout === "content" ? contentLayoutPreview : articleLayoutPreview
return merge(jsonPreview, {
page: {
title: title || "Page title here",
},
}) as IsomerSchema
}, [layout, title])

const utils = trpc.useUtils()

const { mutate, isLoading } = trpc.page.createPage.useMutation({
onSuccess: async () => {
await utils.page.list.invalidate()
onClose()
},
// TOOD: Error handling
karrui marked this conversation as resolved.
Show resolved Hide resolved
})

const handleCreatePage = formMethods.handleSubmit((values) => {
mutate({
siteId,
folderId,
...values,
})
})

const handleNextToDetailScreen = () => {
setCurrentStep(CreatePageFlowStates.Details)
}

const handleBackToLayoutScreen = () => {
setCurrentStep(CreatePageFlowStates.Layout)
}

return {
currentStep,
formMethods,
handleCreatePage,
isLoading,
handleNextToDetailScreen,
handleBackToLayoutScreen,
layoutPreviewJson,
onClose,
currentLayout: layout,
}
}

export const CreatePageWizardProvider = ({
children,
...passthroughProps
}: PropsWithChildren<CreatePageWizardProps>): JSX.Element => {
const values = useCreatePageWizardContext(passthroughProps)
return (
<CreatePageWizardContext.Provider value={values}>
{children}
</CreatePageWizardContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { useEffect } from "react"
import {
Flex,
FormControl,
FormHelperText,
FormLabel,
Input,
InputGroup,
InputLeftAddon,
ModalBody,
ModalHeader,
Stack,
Text,
Wrap,
} from "@chakra-ui/react"
import { Button, FormErrorMessage } from "@opengovsg/design-system-react"
import { Controller } from "react-hook-form"

import { MAX_PAGE_URL_LENGTH, MAX_TITLE_LENGTH } from "~/schemas/page"
import { useCreatePageWizard } from "./CreatePageWizardContext"
import { PreviewLayout } from "./PreviewLayout"

const generatePageUrl = (value: string) => {
return (
value
.toLowerCase()
// Replace non-alphanum characters with hyphen for UX
.replace(/[^a-z0-9]/g, "-")
)
}

export const CreatePageDetailsScreen = () => {
const {
formMethods,
onClose,
handleBackToLayoutScreen,
handleCreatePage,
isLoading,
} = useCreatePageWizard()

const {
register,
control,
watch,
getFieldState,
setValue,
formState: { errors },
} = formMethods

const [title, url] = watch(["title", "permalink"])

/**
* As user edits the Page title, Page URL is updated as an hyphenated form of the page title.
* If user edits Page URL, the “syncing” stops.
*
* 1. adds page title A
* 2. edits page url A
* 3. deletes page title A
* 4. resets to page url
* 5. starts typing new page title B -> page url syncs w new page title B
*/
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", generatePageUrl(title), {
shouldValidate: !!title,
})
}
}, [getFieldState, setValue, title])

return (
<>
<ModalHeader
color="base.content.strong"
borderBottom="1px solid"
borderColor="base.divider.medium"
py="0.75rem"
>
<Stack
justify="space-between"
align="center"
flexDir={{ base: "column", md: "row" }}
>
<Text>Create a new page: Page details</Text>
<Wrap
shouldWrapChildren
flexDirection="row"
justify={{ base: "flex-end", md: "flex-start" }}
align="center"
gap="0.75rem"
>
<Button variant="clear" onClick={onClose} isDisabled={isLoading}>
Cancel
</Button>
<Button
variant="outline"
onClick={handleBackToLayoutScreen}
isDisabled={isLoading}
>
Choose different layout
</Button>
<Button onClick={handleCreatePage} isLoading={isLoading}>
Start editing
</Button>
</Wrap>
</Stack>
</ModalHeader>
<ModalBody p={0} overflow="hidden" bg="white">
<Flex height="100%">
<Stack height="100%" gap="2rem" mt="10vh" px="3rem" py="1rem">
<Stack>
<Text as="h2" textStyle="h4">
What is your page about?
</Text>
<Text textStyle="body-2">You can change these later.</Text>
</Stack>
<Stack gap="1.5rem">
{/* Section 1: Page Title */}
<FormControl isInvalid={!!errors.title}>
<FormLabel color="base.content.strong">
Page title
<FormHelperText color="base.content.default">
Title should be descriptive
</FormHelperText>
</FormLabel>

<Input
placeholder="This is a title for your new page"
{...register("title")}
/>
{errors.title?.message ? (
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
) : (
<FormHelperText mt="0.5rem" color="base.content.medium">
{MAX_TITLE_LENGTH - title.length} characters left
</FormHelperText>
)}
</FormControl>

{/* Section 2: Page URL */}
<FormControl isInvalid={!!errors.permalink}>
<FormLabel>
Page URL
<FormHelperText>
URL should be short and simple
</FormHelperText>
</FormLabel>
<InputGroup>
<InputLeftAddon
bg="interaction.support.disabled"
color="base.divider.strong"
>
your-site.gov.sg/
</InputLeftAddon>
<Controller
control={control}
name="permalink"
render={({ field: { onChange, ...field } }) => (
<Input
borderLeftRadius={0}
placeholder="URL will be autopopulated if left untouched"
{...field}
onChange={(e) => {
onChange(generatePageUrl(e.target.value))
}}
/>
)}
/>
</InputGroup>

{errors.permalink?.message ? (
<FormErrorMessage>
{errors.permalink.message}
</FormErrorMessage>
) : (
<FormHelperText mt="0.5rem" color="base.content.medium">
{MAX_PAGE_URL_LENGTH - url.length} characters left
</FormHelperText>
)}
</FormControl>
</Stack>
</Stack>
<PreviewLayout />
</Flex>
</ModalBody>
</>
)
}
Loading
Loading