Skip to content

Commit

Permalink
feat: add types to JSON columns in prisma schema (#318)
Browse files Browse the repository at this point in the history
### TL;DR

Added the `prisma-json-types-generator` module to the project and associated it with the JSON columns in the Prisma schema. This enables the usage of strongly typed JSON columns in the database.

### What changed?

- Added `prisma-json-types-generator` version `^3.0.4` to `package.json` and `package-lock.json`.
- Updated `schema.prisma` to include a new generator for `prisma-json-types-generator`.
- Added type annotations for JSON fields in various models.
- Created a new `types.ts` file to define types for the JSON columns.
- Updated various parts of the codebase to use the new strongly typed JSON fields.

### How to test?

1. Install the new dependencies by running `npm install`.
2. Verify that the Prisma client is generated with the new JSON types by running `npx prisma generate`.
3. Run the existing tests to ensure that no functionality is broken.
4. Perform manual testing on forms that deal with JSON fields to ensure they are working correctly.

### Why make this change?

This change enhances type safety and developer experience by enabling strongly typed JSON columns in the database. It also prepares the project for more advanced features that rely on typed JSON data.

---
  • Loading branch information
karrui authored Jul 19, 2024
1 parent 6e5076c commit f169fec
Show file tree
Hide file tree
Showing 17 changed files with 302 additions and 136 deletions.
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"pg": "^8.12.0",
"pino": "^9.2.0",
"pino-pretty": "^11.2.1",
"prisma-json-types-generator": "^3.0.4",
"prisma-kysely": "^1.8.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
24 changes: 20 additions & 4 deletions apps/studio/prisma/generated/generatedTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,29 @@ export type Timestamp = ColumnType<Date, Date | string, Date | string>

export interface Blob {
id: GeneratedAlways<number>
content: unknown
/**
* @kyselyType(PrismaJson.BlobJsonContent)
* [BlobJsonContent]
*/
content: PrismaJson.BlobJsonContent
}
export interface Footer {
id: GeneratedAlways<number>
siteId: number
content: unknown
/**
* @kyselyType(PrismaJson.FooterJsonContent)
* [FooterJsonContent]
*/
content: PrismaJson.FooterJsonContent
}
export interface Navbar {
id: GeneratedAlways<number>
siteId: number
content: unknown
/**
* @kyselyType(PrismaJson.NavbarJsonContent)
* [NavbarJsonContent]
*/
content: PrismaJson.NavbarJsonContent
}
export interface Permission {
id: GeneratedAlways<number>
Expand All @@ -41,7 +53,11 @@ export interface Resource {
export interface Site {
id: GeneratedAlways<number>
name: string
config: unknown
/**
* @kyselyType(PrismaJson.SiteJsonConfig)
* [SiteJsonConfig]
*/
config: PrismaJson.SiteJsonConfig
}
export interface SiteMember {
userId: string
Expand Down
29 changes: 23 additions & 6 deletions apps/studio/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ generator client {
provider = "prisma-client-js"
}

/// Always after the prisma-client-js generator
/// Generates types for the JSON columns
/// The relevant types are declared in a separate prisma/types.ts file.
generator json {
provider = "prisma-json-types-generator"
}

generator kysely {
provider = "prisma-kysely"
readOnlyIds = true
Expand Down Expand Up @@ -89,27 +96,37 @@ model Site {
// This is currently put as `Json` for ease of extensibility
// when we lock in what we actually want for site-wide config,
// we should put this in our db table.
/// @kyselyType(PrismaJson.SiteJsonConfig)
/// [SiteJsonConfig]
config Json
navbar Navbar?
footer Footer?
}

model Navbar {
id Int @id @default(autoincrement())
siteId Int @unique
site Site @relation(fields: [siteId], references: [id])
id Int @id @default(autoincrement())
siteId Int @unique
site Site @relation(fields: [siteId], references: [id])
/// @kyselyType(PrismaJson.NavbarJsonContent)
/// [NavbarJsonContent]
content Json
}

model Footer {
id Int @id @default(autoincrement())
siteId Int @unique
site Site @relation(fields: [siteId], references: [id])
id Int @id @default(autoincrement())
siteId Int @unique
site Site @relation(fields: [siteId], references: [id])
/// @kyselyType(PrismaJson.FooterJsonContent)
/// [FooterJsonContent]
content Json
}

model Blob {
id Int @id @default(autoincrement())
/// @kyselyType(PrismaJson.BlobJsonContent)
/// [BlobJsonContent]
content Json
mainResource Resource? @relation("MainBlob")
Expand Down
22 changes: 5 additions & 17 deletions apps/studio/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,10 @@
*
* @link https://www.prisma.io/docs/guides/database/seed-database
*/
import {
type IsomerGeneratedSiteProps,
type IsomerSiteConfigProps,
type IsomerSitemap,
} from "@opengovsg/isomer-components"
import type { IsomerSchema, IsomerSitemap } from "@opengovsg/isomer-components"
import cuid2 from "@paralleldrive/cuid2"

import {
type Footer,
type Navbar,
} from "~/server/modules/resource/resource.types"
import type { Navbar } from "~/server/modules/resource/resource.types"
import { db } from "../src/server/modules/database"

const MOCK_PHONE_NUMBER = "123456789"
Expand All @@ -29,7 +22,7 @@ const ISOMER_ADMINS = [
"hanpu",
]

const PAGE_BLOB = {
const PAGE_BLOB: IsomerSchema = {
version: "0.1.0",
layout: "homepage",
page: {
Expand All @@ -40,7 +33,6 @@ const PAGE_BLOB = {
type: "hero",
variant: "gradient",
alignment: "left",
backgroundColor: "black",
title: "Ministry of Trade and Industry",
subtitle:
"A leading global city of enterprise and talent, a vibrant nation of innovation and opportunity",
Expand Down Expand Up @@ -172,11 +164,7 @@ async function main() {
siteName: "MTI",
logoUrl: "",
search: undefined,
// TODO: Remove siteMap as it is a generated field
siteMap,
isGovernment: true,
} satisfies IsomerSiteConfigProps & {
siteMap: IsomerGeneratedSiteProps["siteMap"]
},
})
.returning("id")
Expand All @@ -192,15 +180,15 @@ async function main() {
privacyStatementLink: "/privacy",
termsOfUseLink: "/terms-of-use",
siteNavItems: FOOTER_ITEMS,
} satisfies Footer,
},
})
.execute()

await db
.insertInto("Navbar")
.values({
siteId,
content: { items: NAV_BAR_ITEMS } satisfies Navbar,
content: NAV_BAR_ITEMS,
})
.execute()

Expand Down
23 changes: 23 additions & 0 deletions apps/studio/prisma/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* This type file is used by prisma-json-types-generator to generate typecasts for
* Json columns in the database to use in the applications.
* This is further used by the `kysely` and `kysely-prisma` libraries to generate
* types for the query builder.
*/

import type {
IsomerPageSchemaType as _IsomerPageSchemaType,
IsomerSchema as _IsomerSchema,
IsomerSiteConfigProps as _IsomerSiteConfigProps,
IsomerSiteWideComponentsProps as _IsomerSiteWideComponentsProps,
} from "@opengovsg/isomer-components"

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace PrismaJson {
type SiteJsonConfig = _IsomerSiteConfigProps
type BlobJsonContent = _IsomerSchema
type NavbarJsonContent = _IsomerSiteWideComponentsProps["navBarItems"]
type FooterJsonContent = _IsomerSiteWideComponentsProps["footerItems"]
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { UseDisclosureReturn } from "@chakra-ui/react"
import type { z } from "zod"
import { useEffect } from "react"
import { useRouter } from "next/router"
import {
FormControl,
FormHelperText,
Expand Down Expand Up @@ -32,7 +33,11 @@ import {
} from "~/schemas/page"
import { trpc } from "~/utils/trpc"

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

const generatePageUrl = (value: string) => {
return (
Expand All @@ -53,6 +58,8 @@ type ClientCreatePageSchema = z.input<typeof clientCreatePageSchema>
export const PageCreateModal = ({
isOpen,
onClose,
siteId,
folderId,
}: PageCreateModalProps): JSX.Element => {
const { mutate, isLoading } = trpc.page.createPage.useMutation({
onSuccess: onClose,
Expand All @@ -62,9 +69,8 @@ export const PageCreateModal = ({
const submitCallback = (values: ClientCreatePageSchema) => {
mutate({
...values,
// TODO: Add siteId to the form
siteId: 1,
folderId: 1,
siteId,
folderId,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function Preview(props: IsomerSchema) {
isGovernment,
environment: "production",
lastUpdated: "3 Apr 2024",
navBarItems: navbar.items,
navBarItems: navbar,
footerItems: footer,
}}
/>
Expand Down
8 changes: 8 additions & 0 deletions apps/studio/src/hooks/useQueryParse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ZodTypeAny } from "zod"
import { useRouter } from "next/router"

export const useQueryParse = <T extends ZodTypeAny>(schema: T) => {
const { query } = useRouter()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return schema.parse(query) as T["_output"]
}
10 changes: 10 additions & 0 deletions apps/studio/src/pages/sites/[siteId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,28 @@ import {
} from "@chakra-ui/react"
import { Button, Menu } from "@opengovsg/design-system-react"
import { BiData, BiFileBlank, BiFolder } from "react-icons/bi"
import { z } from "zod"

import { MenuItem } from "~/components/Menu"
import { ResourceTable } from "~/features/dashboard/components/ResourceTable"
import PageCreateModal from "~/features/editing-experience/components/PageCreateModal"
import { useQueryParse } from "~/hooks/useQueryParse"
import { type NextPageWithLayout } from "~/lib/types"
import { AdminCmsSidebarLayout } from "~/templates/layouts/AdminCmsSidebarLayout"

const sitePageSchema = z.object({
siteId: z.coerce.number(),
})

const SitePage: NextPageWithLayout = () => {
const {
isOpen: isPageCreateModalOpen,
onOpen: onPageCreateModalOpen,
onClose: onPageCreateModalClose,
} = useDisclosure()

const { siteId } = useQueryParse(sitePageSchema)

return (
<>
<VStack w="100%" p="1.75rem" gap="1rem">
Expand Down Expand Up @@ -62,6 +71,7 @@ const SitePage: NextPageWithLayout = () => {
<PageCreateModal
isOpen={isPageCreateModalOpen}
onClose={onPageCreateModalClose}
siteId={siteId}
/>
</>
)
Expand Down
11 changes: 10 additions & 1 deletion apps/studio/src/pages/sites/[siteId]/pages/[pageId]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import { useEffect } from "react"
import { Grid, GridItem } from "@chakra-ui/react"
import { z } from "zod"

import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
import EditPageDrawer from "~/features/editing-experience/components/EditPageDrawer"
import Preview from "~/features/editing-experience/components/Preview"
import { useQueryParse } from "~/hooks/useQueryParse"
import { PageEditingLayout } from "~/templates/layouts/PageEditingLayout"
import { trpc } from "~/utils/trpc"

const editPageSchema = z.object({
pageId: z.coerce.number(),
siteId: z.coerce.number(),
})

function EditPage(): JSX.Element {
const {
setDrawerState,
pageState,
setPageState,
setSnapshot: setEditorState,
} = useEditorDrawerContext()
const { pageId, siteId } = useQueryParse(editPageSchema)

const [{ content: page }] = trpc.page.readPageAndBlob.useSuspenseQuery({
pageId: 1,
pageId,
siteId,
})

useEffect(() => {
Expand Down
23 changes: 7 additions & 16 deletions apps/studio/src/schemas/page.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
import { ISOMER_PAGE_LAYOUTS } from "@opengovsg/isomer-components"
import { z } from "zod"

const PAGE_LAYOUTS = ["content"] as const
const LAYOUT_VALUES = [...Object.values(ISOMER_PAGE_LAYOUTS)]
type LayoutValues = (typeof LAYOUT_VALUES)[number]

export const MAX_TITLE_LENGTH = 150
export const MAX_PAGE_URL_LENGTH = 250

export const getEditPageSchema = z.object({
pageId: z.number().min(1),
})

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({
Expand All @@ -43,8 +33,9 @@ export const createPageSchema = z.object({
.max(MAX_PAGE_URL_LENGTH, {
message: `Page URL should be shorter than ${MAX_PAGE_URL_LENGTH} characters.`,
}),
// TODO: add the actual layouts in here
layout: z.enum(PAGE_LAYOUTS).default("content"),
layout: z
.enum<string, [LayoutValues]>(LAYOUT_VALUES as [LayoutValues])
.default("content"),
siteId: z.number().min(1),
// NOTE: implies that top level pages are allowed
folderId: z.number().min(1).optional(),
Expand Down
Loading

0 comments on commit f169fec

Please sign in to comment.