Skip to content

Commit

Permalink
feat(dnd): allow optimistic loading (#324)
Browse files Browse the repository at this point in the history
* chore: convert

add script

* refactor: resource

use 2 queries cos inner join + update in same query doesn't work

* refactor: component selector

update typings

* feat: editor drawer context

add curr active idx

* feat: editpagedrawer

update so data is dynamic

* chore: componentselector

only paragraph uses component selector

* chore: update convert function

1. add zod validation
2. simplify the function

* chore: component selector

* chore: components selector

update to correct names

* chore: constants

update default prose block

* feat: convert

add convert functions

* chore: typings

* chore: next state

compute next state from block

* feat: update

update UI on save click

* feat: update

update pages automatically

* chore: rename editorstate

* chore: utils

delete obsolete function

* chore: menubar

remove title cos we don't want them to set h1

* chore: remove unused imports

* chore: remove json schema setting

* fix: tiptap

update typings + validation

* chore: componentselector

update to remove unsafe cast

* fix: add snapshot

* chore: remove extra memo

* chore: fix type errors

* chore: remove saving

* chore: style

run lint + format

* chore: fix type errors

* chore: style

run lint + format

* feat: re-order

* feat: optimistic loading

* feat: page router

* fix: run formatter

* chore: prose

add format

* fix: use ref for isomer components schema

* fix: prose

add format

* chore: fix rebase errors

* chore: style

* chore: rebase fixes

* fix: rebase

* chore: style

* chore: typings

* chore: comments

* chore: use siteID

* chore: style

lint fix + style fix

* chore: root state drawer

use query parse

* chore: memo ondragend

* chore: update imports

* chore: componentselector

pas stuff as props to selector

* chore: style

* chore: onerror

shift from inside mutate function to hook

* fix: page schema

min 0

* fix: page id

obtain page id correctly

* fix: resource service

add forUpdate

* fix: blob id conditional

* fix: transaction

put stuff in tx and use safe kysely type

* chore: remove extra dep

* chore: component selector

remove unused props

* ref: db

pass db as explicit arg

* feat(tiptap): save updates to db + invalidate queries (#367)

* fix: complex editor

wire save through to db

* refactor: complex editor state drawer

refactor so props correctness handled otuside

* fix: update page

invalidate on update

* fix: updatePageBlob

now requires db

* feat: tiptap component

update db on save + invalidate old queries

* chore: use exported schema

* fix: rebase

* fix: lint

update lint

---------

Co-authored-by: Kar Rui Lau <[email protected]>
  • Loading branch information
seaerchin and karrui authored Jul 30, 2024
1 parent 8d753a3 commit 57d1f2f
Show file tree
Hide file tree
Showing 10 changed files with 361 additions and 79 deletions.
17 changes: 12 additions & 5 deletions apps/studio/src/components/PageEditor/ComponentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ import {
} from "react-icons/bi"

import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import { useQueryParse } from "~/hooks/useQueryParse"
import { editPageSchema } from "~/pages/sites/[siteId]/pages/[pageId]"
import { type DrawerState } from "~/types/editorDrawer"
import { trpc } from "~/utils/trpc"
import { DEFAULT_BLOCKS } from "./constants"
Expand Down Expand Up @@ -99,7 +97,12 @@ function BlockItem({
)
}

function ComponentSelector() {
interface ComponentSelectorProps {
siteId: number
pageId: number
}

function ComponentSelector({ pageId, siteId }: ComponentSelectorProps) {
const {
setCurrActiveIdx,
savedPageState,
Expand All @@ -108,8 +111,12 @@ function ComponentSelector() {
setPreviewPageState,
setAddedBlock,
} = useEditorDrawerContext()

const { pageId, siteId } = useQueryParse(editPageSchema)
const utils = trpc.useUtils()
const { mutate } = trpc.page.updatePageBlob.useMutation({
onSuccess: async () => {
await utils.page.readPageAndBlob.invalidate({ pageId, siteId })
},
})
const [page] = trpc.page.readPageAndBlob.useSuspenseQuery({
pageId,
siteId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,34 @@ import Ajv from "ajv"
import { BiDollar, BiTrash, BiX } from "react-icons/bi"

import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import { useQueryParse } from "~/hooks/useQueryParse"
import { editPageSchema } from "~/pages/sites/[siteId]/pages/[pageId]"
import { trpc } from "~/utils/trpc"
import { DeleteBlockModal } from "./DeleteBlockModal"
import FormBuilder from "./form-builder/FormBuilder"

const ajv = new Ajv({ strict: false, logger: false })

export default function ComplexEditorStateDrawer(): JSX.Element {
const {
currActiveIdx,
setDrawerState,
currActiveIdx,
savedPageState,
setSavedPageState,
previewPageState,
setPreviewPageState,
} = useEditorDrawerContext()
const { pageId, siteId } = useQueryParse(editPageSchema)
const [{ content: pageContent }] = trpc.page.readPageAndBlob.useSuspenseQuery(
{ siteId, pageId },
)
const utils = trpc.useUtils()

const { mutate, isLoading } = trpc.page.updatePageBlob.useMutation({
onSuccess: async () => {
await utils.page.readPageAndBlob.invalidate({ pageId, siteId })
},
})
const {
isOpen: isDeleteBlockModalOpen,
onOpen: onDeleteBlockModalOpen,
Expand Down Expand Up @@ -160,7 +174,13 @@ export default function ComplexEditorStateDrawer(): JSX.Element {
onClick={() => {
setDrawerState({ state: "root" })
setSavedPageState(previewPageState)
mutate({
pageId,
siteId,
content: JSON.stringify(previewPageState),
})
}}
isLoading={isLoading}
>
Save changes
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Ajv from "ajv"

import ComponentSelector from "~/components/PageEditor/ComponentSelector"
import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import { useQueryParse } from "~/hooks/useQueryParse"
import { editPageSchema } from "~/pages/sites/[siteId]/pages/[pageId]"
import AdminModeStateDrawer from "./AdminModeStateDrawer"
import ComplexEditorStateDrawer from "./ComplexEditorStateDrawer"
import MetadataEditorStateDrawer from "./MetadataEditorStateDrawer"
Expand All @@ -22,6 +24,8 @@ export function EditPageDrawer(): JSX.Element {
currActiveIdx,
} = useEditorDrawerContext()

const { pageId, siteId } = useQueryParse(editPageSchema)

const inferAsProse = (component?: IsomerComponent): ProseProps => {
if (!component) {
throw new Error(`Expected component of type prose but got undefined`)
Expand All @@ -46,7 +50,7 @@ export function EditPageDrawer(): JSX.Element {
case "adminMode":
return <AdminModeStateDrawer />
case "addBlock":
return <ComponentSelector />
return <ComponentSelector siteId={siteId} pageId={pageId} />
case "nativeEditor": {
const component = previewPageState.content[currActiveIdx]
return <TipTapComponent content={inferAsProse(component)} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@ import Ajv from "ajv"
import { BiDollar, BiX } from "react-icons/bi"

import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import { useQueryParse } from "~/hooks/useQueryParse"
import { editPageSchema } from "~/pages/sites/[siteId]/pages/[pageId]"
import { trpc } from "~/utils/trpc"
import FormBuilder from "./form-builder/FormBuilder"

const ajv = new Ajv({ strict: false, logger: false })

export default function MetadataEditorStateDrawer(): JSX.Element {
const { pageId, siteId } = useQueryParse(editPageSchema)
const utils = trpc.useUtils()
const [{ content: pageContent }] = trpc.page.readPageAndBlob.useSuspenseQuery(
{ siteId, pageId },
)
const { mutate } = trpc.page.updatePageBlob.useMutation({
onSuccess: async () => {
await utils.page.readPageAndBlob.invalidate({ pageId, siteId })
},
})
const {
setDrawerState,
savedPageState,
Expand Down Expand Up @@ -105,6 +118,11 @@ export default function MetadataEditorStateDrawer(): JSX.Element {
onClick={() => {
setDrawerState({ state: "root" })
setSavedPageState(previewPageState)
mutate({
pageId,
siteId,
content: JSON.stringify(previewPageState),
})
}}
>
Save changes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DropResult } from "@hello-pangea/dnd"
import { useCallback } from "react"
import {
Box,
Button,
Expand All @@ -9,12 +10,16 @@ import {
VStack,
} from "@chakra-ui/react"
import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd"
import { useToast } from "@opengovsg/design-system-react"
import { getComponentSchema } from "@opengovsg/isomer-components"
import { BiGridVertical } from "react-icons/bi"
import { BsPlus } from "react-icons/bs"

import { BlockEditingPlaceholder } from "~/components/Svg"
import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import { useQueryParse } from "~/hooks/useQueryParse"
import { editPageSchema } from "~/pages/sites/[siteId]/pages/[pageId]"
import { trpc } from "~/utils/trpc"
import { ActivateAdminMode } from "./ActivateAdminMode"

export default function RootStateDrawer() {
Expand All @@ -26,25 +31,65 @@ export default function RootStateDrawer() {
setPreviewPageState,
} = useEditorDrawerContext()

const onDragEnd = (result: DropResult) => {
if (!result.destination || !savedPageState) return

const updatedBlocks = Array.from(savedPageState.content)
// Remove block at source index
const [movedBlock] = updatedBlocks.splice(result.source.index, 1)
if (movedBlock) {
// Insert at destination index
updatedBlocks.splice(result.destination.index, 0, movedBlock)
}

const newPageState = {
...savedPageState,
content: updatedBlocks,
}

setPreviewPageState(newPageState)
setSavedPageState(newPageState)
}
const { pageId, siteId } = useQueryParse(editPageSchema)
const { mutate } = trpc.page.reorderBlock.useMutation({
onError: (error, variables) => {
// NOTE: rollback to last known good state
// @ts-expect-error Our zod validator runs between frontend and backend
// and the error type is automatically inferred from the zod validator.
// However, the type that we use on `pageState` is the full type
// because `Preview` (amongst other things) requires the other properties on the actual schema type
setPreviewPageState(variables.blocks)
// @ts-expect-error See above
setSavedPageState(variables.blocks)
toast({
title: "Failed to update blocks",
description: error.message,
})
},
})

const toast = useToast({ status: "error" })

const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination || !savedPageState) return

const from = result.source.index
const to = result.destination.index
const contentLength = savedPageState?.content.length ?? 0

if (from >= contentLength || to >= contentLength || from < 0 || to < 0)
return

// NOTE: We eagerly update their page state here
// and if it fails on the backend,
// we rollback to what we passed them
const updatedBlocks = Array.from(savedPageState.content)
const [movedBlock] = updatedBlocks.splice(from, 1)

if (!!movedBlock) {
updatedBlocks.splice(to, 0, movedBlock)
const newPageState = {
...savedPageState,
content: updatedBlocks,
}
setPreviewPageState(newPageState)
setSavedPageState(newPageState)
}

// NOTE: drive an update to the db with the updated index
mutate({ pageId, from, to, blocks: savedPageState.content, siteId })
},
[
mutate,
pageId,
savedPageState,
setPreviewPageState,
setSavedPageState,
siteId,
],
)

const isHeroFixedBlock =
savedPageState?.layout === "homepage" &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { BiText, BiTrash, BiX } from "react-icons/bi"

import { PROSE_COMPONENT_NAME } from "~/constants/formBuilder"
import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import { useQueryParse } from "~/hooks/useQueryParse"
import { editPageSchema } from "~/pages/sites/[siteId]/pages/[pageId]"
import { trpc } from "~/utils/trpc"
import { DeleteBlockModal } from "./DeleteBlockModal"
import { TiptapEditor } from "./form-builder/renderers/TipTapEditor"

Expand All @@ -23,8 +26,8 @@ interface TipTapComponentProps {

function TipTapComponent({ content }: TipTapComponentProps) {
const {
setDrawerState,
savedPageState,
setDrawerState,
setSavedPageState,
previewPageState,
setPreviewPageState,
Expand All @@ -36,6 +39,8 @@ function TipTapComponent({ content }: TipTapComponentProps) {
onClose: onDeleteBlockModalClose,
} = useDisclosure()

const { pageId, siteId } = useQueryParse(editPageSchema)

if (!previewPageState || !savedPageState) return

const updatePageState = (editorContent: JSONContent) => {
Expand All @@ -62,6 +67,17 @@ function TipTapComponent({ content }: TipTapComponentProps) {
setDrawerState({ state: "root" })
}

const utils = trpc.useUtils()

const { mutate } = trpc.page.updatePageBlob.useMutation({
onSuccess: async () => {
await utils.page.readPageAndBlob.invalidate({ pageId, siteId })
},
})
const [{ content: pageContent }] = trpc.page.readPageAndBlob.useSuspenseQuery(
{ siteId, pageId },
)

// TODO: Add a loading state or use suspsense
return (
<>
Expand Down Expand Up @@ -125,6 +141,11 @@ function TipTapComponent({ content }: TipTapComponentProps) {
onClick={() => {
setDrawerState({ state: "root" })
setSavedPageState(previewPageState)
mutate({
pageId,
siteId,
content: JSON.stringify(previewPageState),
})
}}
>
Save changes
Expand Down
28 changes: 28 additions & 0 deletions apps/studio/src/schemas/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ export const getEditPageSchema = z.object({
siteId: z.number().min(1),
})

export const reorderBlobSchema = z.object({
pageId: z.number().min(1),
from: z.number().min(0),
to: z.number().min(0),
siteId: z.number().min(1),
blocks: z.array(
z
.object({
type: z.string(),
})
.passthrough(),
),
})

export const updatePageSchema = getEditPageSchema.extend({
// NOTE: We allow both to be empty now,
// in which case this is a no-op.
// We are ok w/ this because it doesn't
// incur any db writes
parentId: z.number().min(1).optional(),
pageName: z.string().min(1).optional(),
})

export const updatePageBlobSchema = getEditPageSchema.extend({
content: z.string(),
siteId: z.number().min(1),
})

export const createPageSchema = z.object({
title: z
.string({
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/src/server/modules/database/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Transaction as NativeTransaction } from "kysely"
import { Kysely as NativeKysely } from "kysely"

import type { DB } from "~prisma/generated/generatedTypes"

/**
* Do not edit ~prisma/generated/generatedTypes and ~prisma/generated/generatedEnums files directly.
* They are auto generated by kysely-prisma.
Expand Down Expand Up @@ -30,4 +32,4 @@ export type Transaction<DB> = Omit<NativeTransaction<DB>, "transaction">
/**
* SafeKysely is a type that can be either a Kysely or a Transaction type
*/
export type SafeKysely<DB> = Transaction<DB> | Kysely<DB>
export type SafeKysely = Transaction<DB> | Kysely<DB>
Loading

0 comments on commit 57d1f2f

Please sign in to comment.