diff --git a/.github/workflows/mergify.yml b/.github/workflows/mergify.yml new file mode 100644 index 000000000..9a35cc0ed --- /dev/null +++ b/.github/workflows/mergify.yml @@ -0,0 +1,39 @@ +pull_request_rules: + - name: Approve and merge non-major version dependabot upgrades + conditions: + - author~=^dependabot\[bot\]$ + - -dependabot-update-type = version-update:semver-major + actions: + review: + type: APPROVE + merge: + method: squash + + - name: Approve and merge Snyk.io upgrades + conditions: + - author=isomeradmin + - title~=^\[Snyk\] + - base = develop + actions: + review: + type: APPROVE + merge: + method: squash + + - name: Automatically mark a PR as draft if [WIP] is in the title + conditions: + - title~=(?i)\[wip\] + actions: + edit: + draft: True + + - name: Ping Isomer members for stale open PRs (>1 month since last activity) + conditions: + - updated-at<30 days ago + - -closed + actions: + request_reviews: + teams: + - "@isomerpages/iso-engineers" + comment: + message: This pull request has been stale for more than 30 days! Could someone please take a look at it @isomerpages/iso-engineers diff --git a/apps/studio/package.json b/apps/studio/package.json index 1ea1e38eb..bf7467dab 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -50,7 +50,7 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@hello-pangea/dnd": "^16.6.0", - "@hookform/resolvers": "^3.3.4", + "@hookform/resolvers": "^3.6.0", "@jsonforms/core": "^3.3.0", "@jsonforms/material-renderers": "^3.3.0", "@jsonforms/react": "^3.3.0", @@ -65,7 +65,7 @@ "@sendgrid/mail": "^8.1.3", "@storybook/jest": "0.2.3", "@tanstack/react-query": "^4.36.1", - "@tanstack/react-query-devtools": "^5.45.1", + "@tanstack/react-query-devtools": "^4.36.1", "@tiptap/extension-link": "2.1.15", "@tiptap/extension-placeholder": "2.4.0", "@tiptap/extension-subscript": "^2.4.0", @@ -102,6 +102,7 @@ "react-error-boundary": "^4.0.12", "react-hook-form": "^7.51.4", "react-icons": "^5.2.0", + "sass": "^1.77.6", "superjson": "^2.2.1", "trpc-openapi": "^1.2.0", "usehooks-ts": "^2.9.2", @@ -112,7 +113,7 @@ "devDependencies": { "@babel/plugin-transform-class-properties": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/preset-env": "^7.23.8", + "@babel/preset-env": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@chakra-ui/cli": "^2.4.1", "@playwright/test": "^1.40.1", @@ -139,7 +140,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.2", "eslint-plugin-react": "^7.34.1", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-storybook": "0.8.0", "k6-trpc": "^1.0.0", "mockdate": "^3.0.5", diff --git a/apps/studio/prisma/generated/generatedTypes.ts b/apps/studio/prisma/generated/generatedTypes.ts index 9f1dc800e..0b4602d32 100644 --- a/apps/studio/prisma/generated/generatedTypes.ts +++ b/apps/studio/prisma/generated/generatedTypes.ts @@ -11,6 +11,16 @@ export type Blob = { id: string content: unknown } +export type Footer = { + id: string + siteId: string + content: unknown +} +export type Navbar = { + id: string + siteId: string + content: unknown +} export type Permission = { id: string resourceId: string @@ -27,6 +37,7 @@ export type Resource = { export type Site = { id: string name: string + config: unknown } export type SiteMember = { userId: string @@ -47,6 +58,8 @@ export type VerificationToken = { } export type DB = { Blob: Blob + Footer: Footer + Navbar: Navbar Permission: Permission Resource: Resource Site: Site diff --git a/apps/studio/prisma/migrations/20240621081149_footer_navbar/migration.sql b/apps/studio/prisma/migrations/20240621081149_footer_navbar/migration.sql new file mode 100644 index 000000000..252f90caf --- /dev/null +++ b/apps/studio/prisma/migrations/20240621081149_footer_navbar/migration.sql @@ -0,0 +1,58 @@ +/* + Warnings: + + - Added the required column `Config` to the `Site` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Site" ADD COLUMN "Config" JSONB NOT NULL; + +-- CreateTable +CREATE TABLE "Navbar" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "description" TEXT NOT NULL, + "siteId" TEXT NOT NULL, + + CONSTRAINT "Navbar_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NavbarItems" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "description" TEXT NOT NULL, + "navbarId" TEXT NOT NULL, + + CONSTRAINT "NavbarItems_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Footer" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "siteId" TEXT NOT NULL, + "contactUsLink" TEXT, + "feedbackFormLink" TEXT, + "privacyStatementLink" TEXT, + "termsOfUseLink" TEXT, + + CONSTRAINT "Footer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Navbar_siteId_key" ON "Navbar"("siteId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Footer_siteId_key" ON "Footer"("siteId"); + +-- AddForeignKey +ALTER TABLE "Navbar" ADD CONSTRAINT "Navbar_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NavbarItems" ADD CONSTRAINT "NavbarItems_navbarId_fkey" FOREIGN KEY ("navbarId") REFERENCES "Navbar"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Footer" ADD CONSTRAINT "Footer_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/studio/prisma/migrations/20240621081908_casing/migration.sql b/apps/studio/prisma/migrations/20240621081908_casing/migration.sql new file mode 100644 index 000000000..9dda2aa6f --- /dev/null +++ b/apps/studio/prisma/migrations/20240621081908_casing/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `Config` on the `Site` table. All the data in the column will be lost. + - Added the required column `config` to the `Site` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Site" DROP COLUMN "Config", +ADD COLUMN "config" JSONB NOT NULL; diff --git a/apps/studio/prisma/migrations/20240624071353_update_to_blob/migration.sql b/apps/studio/prisma/migrations/20240624071353_update_to_blob/migration.sql new file mode 100644 index 000000000..a7c56be9d --- /dev/null +++ b/apps/studio/prisma/migrations/20240624071353_update_to_blob/migration.sql @@ -0,0 +1,33 @@ +/* + Warnings: + + - You are about to drop the column `contactUsLink` on the `Footer` table. All the data in the column will be lost. + - You are about to drop the column `feedbackFormLink` on the `Footer` table. All the data in the column will be lost. + - You are about to drop the column `privacyStatementLink` on the `Footer` table. All the data in the column will be lost. + - You are about to drop the column `termsOfUseLink` on the `Footer` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `Navbar` table. All the data in the column will be lost. + - You are about to drop the column `name` on the `Navbar` table. All the data in the column will be lost. + - You are about to drop the column `url` on the `Navbar` table. All the data in the column will be lost. + - You are about to drop the `NavbarItems` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `content` to the `Footer` table without a default value. This is not possible if the table is not empty. + - Added the required column `content` to the `Navbar` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "NavbarItems" DROP CONSTRAINT "NavbarItems_navbarId_fkey"; + +-- AlterTable +ALTER TABLE "Footer" DROP COLUMN "contactUsLink", +DROP COLUMN "feedbackFormLink", +DROP COLUMN "privacyStatementLink", +DROP COLUMN "termsOfUseLink", +ADD COLUMN "content" JSONB NOT NULL; + +-- AlterTable +ALTER TABLE "Navbar" DROP COLUMN "description", +DROP COLUMN "name", +DROP COLUMN "url", +ADD COLUMN "content" JSONB NOT NULL; + +-- DropTable +DROP TABLE "NavbarItems"; diff --git a/apps/studio/prisma/migrations/20240624130652_/migration.sql b/apps/studio/prisma/migrations/20240624130652_/migration.sql new file mode 100644 index 000000000..cba241838 --- /dev/null +++ b/apps/studio/prisma/migrations/20240624130652_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `Footer` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Footer" DROP COLUMN "name"; diff --git a/apps/studio/prisma/schema.prisma b/apps/studio/prisma/schema.prisma index a2086a95e..113250f8d 100644 --- a/apps/studio/prisma/schema.prisma +++ b/apps/studio/prisma/schema.prisma @@ -48,8 +48,8 @@ model User { phone String preferredName String? - Permission Permission[] - SiteMembers SiteMember[] + permission Permission[] + siteMembers SiteMember[] } model Permission { @@ -66,8 +66,29 @@ model Permission { model Site { id String @id @default(cuid()) name String @unique - Resources Resource[] - SiteMembers SiteMember[] + resources Resource[] + siteMembers SiteMember[] + // NOTE: This is `theme/isGovernment/sitemap` + // 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. + config Json + navbar Navbar? + footer Footer? +} + +model Navbar { + id String @id @default(cuid()) + siteId String @unique + site Site @relation(fields: [siteId], references: [id]) + content Json +} + +model Footer { + id String @id @default(cuid()) + siteId String @unique + site Site @relation(fields: [siteId], references: [id]) + content Json } model Blob { @@ -75,7 +96,7 @@ model Blob { content Json // Should the blob be deleted on deletion of the resource? - Resource Resource? + resource Resource? } model SiteMember { diff --git a/apps/studio/prisma/seed.ts b/apps/studio/prisma/seed.ts index 9e1cb691b..f73aaaee5 100644 --- a/apps/studio/prisma/seed.ts +++ b/apps/studio/prisma/seed.ts @@ -3,10 +3,98 @@ * * @link https://www.prisma.io/docs/guides/database/seed-database */ +import { type SiteConfig } from '~/server/modules/site/site.types' +import { + type Navbar, + type Footer, +} from '~/server/modules/resource/resource.types' import { db } from '../src/server/modules/database' +const NAV_BAR_ITEMS = [ + { + name: 'Expandable nav item', + url: '/item-one', + items: [ + { + name: "PA's network one", + url: '/item-one/pa-network-one', + description: 'Click here and brace yourself for mild disappointment.', + }, + { + name: "PA's network two", + url: '/item-one/pa-network-two', + description: 'Click here and brace yourself for mild disappointment.', + }, + { + name: "PA's network three", + url: '/item-one/pa-network-three', + }, + { + name: "PA's network four", + url: '/item-one/pa-network-four', + description: + 'Click here and brace yourself for mild disappointment. This one has a pretty long one', + }, + { + name: "PA's network five", + url: '/item-one/pa-network-five', + description: + 'Click here and brace yourself for mild disappointment. This one has a pretty long one', + }, + { + name: "PA's network six", + url: '/item-one/pa-network-six', + description: 'Click here and brace yourself for mild disappointment.', + }, + ], + }, +] + async function main() { - // Nothing + await db + .insertInto('Site') + .values({ + id: '1', + name: 'Ministry of Trade and Industry', + config: { + theme: 'isomer-next', + sitemap: { + siblingTitles: [], + childrenTitles: [], + parentTitle: '', + }, + isGovernment: true, + } satisfies SiteConfig, + }) + .execute() + + await db + .insertInto('Footer') + .values({ + id: '1', + siteId: '1', + content: { + name: 'A foot', + contactUsLink: '/contact-us', + feedbackFormLink: 'https://www.form.gov.sg', + privacyStatementLink: '/privacy', + termsOfUseLink: '/terms-of-use', + } satisfies Footer, + }) + .execute() + + await db + .insertInto('Navbar') + .values({ + id: '1', + siteId: '1', + content: { + name: 'navi', + url: 'www.isomer.gov.sg', + items: NAV_BAR_ITEMS, + } satisfies Navbar, + }) + .execute() } main() diff --git a/apps/studio/src/components/PageEditor/Editor.tsx b/apps/studio/src/components/PageEditor/Editor.tsx deleted file mode 100644 index 53a20a597..000000000 --- a/apps/studio/src/components/PageEditor/Editor.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - and, - rankWith, - schemaMatches, - scopeEndsWith, - type JsonFormsRendererRegistryEntry, -} from '@jsonforms/core' -import { materialCells, materialRenderers } from '@jsonforms/material-renderers' -import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react' -import MainTiptapEditor from './MainTipTapEditor' -import { ThemeProvider, CssBaseline } from '@mui/material' -import { createTheme } from '@mui/material/styles' -import { Box } from '@chakra-ui/react' -import ComponentSelector from './ComponentSelector' - -const jsonFormsTheme = createTheme() - -const mainTiptapRenderer = { - tester: rankWith( - 5, //increase rank as needed - and( - scopeEndsWith('/content'), - schemaMatches( - (schema) => - Object.prototype.hasOwnProperty.call(schema, 'minItems') && - schema.minItems === 1, - ), - ), - ), - renderer: withJsonFormsControlProps(MainTiptapEditor), -} - -const nullRenderer = { - tester: rankWith( - 5, //increase rank as needed - and( - scopeEndsWith('/content'), - schemaMatches( - (schema) => - Object.prototype.hasOwnProperty.call(schema, 'minItems') && - schema.minItems === 0, - ), - ), - ), - renderer: withJsonFormsControlProps(() => <>), -} - -// const proseBlockEditor = { -// tester: rankWith( -// 2, //increase rank as needed -// scopeEndsWith('/content'), -// ), -// renderer: withJsonFormsControlProps(ProseBlockEditor), -// } - -const renderers: JsonFormsRendererRegistryEntry[] = [ - ...materialRenderers, - mainTiptapRenderer, - nullRenderer, - // proseBlockEditor, -] - -const Editor = ({ jsonSchema, editorValue, onChange }: any) => { - return ( - console.log('close')} - onProceed={(componentType) => console.log(componentType)} - /> - ) -} - -export default Editor diff --git a/apps/studio/src/components/PageEditor/MainTipTapEditor.tsx b/apps/studio/src/components/PageEditor/MainTipTapEditor.tsx deleted file mode 100644 index ae404369e..000000000 --- a/apps/studio/src/components/PageEditor/MainTipTapEditor.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { Bold } from '@tiptap/extension-bold' -import { BulletList } from '@tiptap/extension-bullet-list' -import { Document } from '@tiptap/extension-document' -import { Dropcursor } from '@tiptap/extension-dropcursor' -import { Gapcursor } from '@tiptap/extension-gapcursor' -import { Heading } from '@tiptap/extension-heading' -import { History } from '@tiptap/extension-history' -import { HorizontalRule } from '@tiptap/extension-horizontal-rule' -import { Italic } from '@tiptap/extension-italic' -import { ListItem } from '@tiptap/extension-list-item' -import { OrderedList } from '@tiptap/extension-ordered-list' -import { Paragraph } from '@tiptap/extension-paragraph' -import { Strike } from '@tiptap/extension-strike' -import { Subscript } from '@tiptap/extension-subscript' -import { Superscript } from '@tiptap/extension-superscript' -import { Table } from '@tiptap/extension-table' -import { TableRow } from '@tiptap/extension-table-row' -import { TableCell } from '@tiptap/extension-table-cell' -import { TableHeader } from '@tiptap/extension-table-header' -import { Text } from '@tiptap/extension-text' -import { Underline } from '@tiptap/extension-underline' -import { Extension, Node } from '@tiptap/core' - -import { - EditorContent, - useEditor, - NodeViewWrapper, - ReactNodeViewRenderer, -} from '@tiptap/react' -import type { CustomRendererProps } from './types' -import MenuBar from './MenuBar' -import { Box } from '@chakra-ui/react' - -const convertToTiptap: any = (value: any) => { - const keys = Object.keys(value) - - if (!keys.includes('content')) { - const { type, ...rest } = value - - if (type === 'text' && keys.includes('text')) { - const { text, ...last } = rest - return { type, attrs: { ...last }, text } - } - - return { type, attrs: { ...rest } } - } - - const { type, content, ...rest } = value - return { - type, - content: content.map((node: any) => convertToTiptap(node)), - attrs: { ...rest }, - } -} - -const convertFromTiptap: any = (value: any) => { - const keys = Object.keys(value) - - if (!keys.includes('content')) { - if (keys.includes('attrs')) { - const { attrs, ...rest } = value - return { - ...rest, - ...Object.fromEntries( - Object.keys(attrs).map((key) => { - if (attrs[key] === null) { - return [key, ''] - } - - return [key, attrs[key]] - }), - ), - } - } - return { ...value } - } - - const { content, ...rest } = value - - if (keys.includes('attrs')) { - const { attrs, ...last } = rest - return { - ...last, - ...Object.fromEntries( - Object.keys(attrs).map((key) => { - if (attrs[key] === null) { - return [key, ''] - } - - return [key, attrs[key]] - }), - ), - content: content.map((node: any) => convertFromTiptap(node)), - } - } - return { - ...rest, - content: value.content.map((node: any) => convertFromTiptap(node)), - } -} - -const CustomNode = ({ type }: any) => { - return ( - - - {type} - - - ) -} - -declare module '@tiptap/core' { - interface Commands { - isomer: { - // Add a new complex component - createComplexComponent: (component: string) => ReturnType - } - } -} - -const MainTiptapEditor = ({ - data, - rootSchema, - handleChange, - path, -}: CustomRendererProps) => { - const complex = Object.keys(rootSchema.components.complex).map((component) => - Node.create({ - name: component, - group: 'block', - draggable: true, - selectable: true, - atom: true, - addAttributes() { - return Object.fromEntries( - Object.keys(rootSchema.components.complex[component].properties) - .filter((key) => key !== 'type') - .map((key) => [key, { default: '' }]), - ) - }, - - renderHTML() { - return [this.name, {}, 0] - }, - addNodeView() { - return ReactNodeViewRenderer((props: any) => - CustomNode({ type: component, ...props }), - ) - }, - }), - ) - - const editor = useEditor({ - extensions: [ - // Blockquote, - Bold, - BulletList.extend({ - name: 'unorderedlist', - }), - // Code, - // CodeBlock, - Document, - Dropcursor, - Gapcursor, - // HardBreak, - Heading, - History, - HorizontalRule.extend({ - name: 'divider', - }), - Italic, - ListItem, - OrderedList.extend({ - name: 'orderedlist', - }), - Paragraph, - Strike, - Subscript, - Superscript, - Table, - TableRow, - TableCell, - TableHeader, - Text, - Underline, - ...complex, - Extension.create({ - name: 'isomer', - addCommands() { - return { - createComplexComponent: - (component: string) => - ({ tr, dispatch, editor }) => { - const { selection } = tr - const parentNode = editor.schema.nodes[component] - if (!parentNode) return false - const node = parentNode.create({}) - - if (dispatch) { - tr.replaceRangeWith(selection.from, selection.to, node) - } - - return true - }, - } - }, - }), - ], - content: { - type: 'doc', - content: - data !== undefined - ? convertToTiptap( - Object.fromEntries( - Object.keys(data).map((key) => [key, data[key]]), - ), - ) - : [], - }, - onUpdate({ editor }) { - const newValue = convertFromTiptap(editor.getJSON()) - handleChange(path, newValue.content) - }, - }) - - return ( - - - - - ) -} - -export default MainTiptapEditor diff --git a/apps/studio/src/components/PageEditor/MenuBar.tsx b/apps/studio/src/components/PageEditor/MenuBar.tsx index 3bb44a1d0..1462e45f8 100644 --- a/apps/studio/src/components/PageEditor/MenuBar.tsx +++ b/apps/studio/src/components/PageEditor/MenuBar.tsx @@ -1,36 +1,118 @@ import { - BiX, + Box, + Divider, + HStack, + Icon, + MenuButtonProps, + MenuListProps, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + VStack, +} from '@chakra-ui/react' +import { Button, Menu } from '@opengovsg/design-system-react' +import { Editor } from '@tiptap/react' +import { BiBold, + BiChevronDown, + BiChevronUp, + BiCodeAlt, + BiFile, + BiImageAdd, BiItalic, - BiUnderline, - BiStrikethrough, + BiLink, BiListOl, BiListUl, - BiTable, BiPlus, - BiUndo, BiRedo, + BiStrikethrough, + BiTable, + BiUnderline, + BiUndo, } from 'react-icons/bi' -import { - Bs2Circle, - Bs3Circle, - BsSuperscript, - BsSubscript, -} from 'react-icons/bs' -import type { Editor } from '@tiptap/core' -import { Divider, Wrap } from '@chakra-ui/react' +import { MdSubscript, MdSuperscript } from 'react-icons/md' +import { IconType } from 'react-icons/lib' + +import { MenuItem } from './MenuItem' + +interface MenuBarItem { + type: 'item' + title: string + icon?: IconType + textStyle?: string + useSecondaryColor?: boolean + leftItem?: JSX.Element + action: () => void + isActive?: () => boolean +} + +interface MenuBarDivider { + type: 'divider' + isHidden?: boolean +} + +interface MenuBarVerticalList { + type: 'vertical-list' + buttonWidth: MenuButtonProps['width'] + menuWidth: MenuListProps['width'] + defaultTitle: string + items: MenuBarItem[] + isHidden?: boolean +} -const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { - const items: any[] = [ +interface MenuBarHorizontalList { + type: 'horizontal-list' + label: string + defaultIcon: IconType + items: MenuBarItem[] + isHidden?: boolean +} + +interface MenuBarDetailedItem { + name: string + description: string + icon: IconType + action: () => void + isHidden?: boolean +} + +interface MenuBarDetailedList { + type: 'detailed-list' + label: string + icon: IconType + items: MenuBarDetailedItem[] + isHidden?: boolean +} + +type MenuBarEntry = + | MenuBarDivider + | MenuBarVerticalList + | MenuBarHorizontalList + | MenuBarDetailedList + | MenuBarItem + +export const MenuBar = ({ editor }: { editor: Editor }) => { + const items: MenuBarEntry[] = [ { type: 'vertical-list', buttonWidth: '9rem', menuWidth: '19rem', - defaultTitle: 'Heading 2', + defaultTitle: 'Heading 1', items: [ { type: 'item', - title: 'Heading 2', + title: 'Title', + textStyle: 'h1', + useSecondaryColor: true, + action: () => + editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive('heading', { level: 1 }), + }, + { + type: 'item', + title: 'Heading 1', textStyle: 'h2', useSecondaryColor: true, action: () => @@ -39,7 +121,7 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { }, { type: 'item', - title: 'Heading 3', + title: 'Heading 2', textStyle: 'h3', useSecondaryColor: true, action: () => @@ -48,7 +130,7 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { }, { type: 'item', - title: 'Heading 4', + title: 'Heading 3', textStyle: 'h4', useSecondaryColor: true, action: () => @@ -57,39 +139,21 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { }, { type: 'item', - title: 'Heading 5', - textStyle: 'h5', - useSecondaryColor: true, - action: () => - editor.chain().focus().toggleHeading({ level: 5 }).run(), - isActive: () => editor.isActive('heading', { level: 5 }), - }, - { - type: 'item', - title: 'Heading 6', - textStyle: 'h6', + title: 'Quote block', + textStyle: 'body-1', useSecondaryColor: true, - action: () => - editor.chain().focus().toggleHeading({ level: 6 }).run(), - isActive: () => editor.isActive('heading', { level: 6 }), + leftItem: ( + + ), + action: () => editor.chain().focus().toggleBlockquote().run(), + isActive: () => editor.isActive('blockquote'), }, - // { - // type: "item", - // title: "Quote block", - // textStyle: "body-1", - // useSecondaryColor: true, - // leftItem: ( - // - // ), - // action: () => editor.chain().focus().toggleBlockquote().run(), - // isActive: () => editor.isActive("blockquote"), - // }, { type: 'item', title: 'Paragraph', @@ -100,27 +164,6 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { }, ], }, - { - type: 'item', - icon: Bs2Circle, - title: 'Heading 2', - action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), - isActive: () => editor.isActive('heading', { level: 2 }), - }, - { - type: 'item', - icon: Bs3Circle, - title: 'Heading 3', - action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), - isActive: () => editor.isActive('heading', { level: 3 }), - }, - { - type: 'item', - icon: BiX, - title: 'Paragraph', - action: () => editor.chain().focus().clearNodes().unsetAllMarks().run(), - isActive: () => editor.isActive('paragraph'), - }, { type: 'divider', }, @@ -154,14 +197,14 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { }, { type: 'item', - icon: BsSuperscript, + icon: MdSuperscript, title: 'Superscript', action: () => editor.chain().focus().toggleSuperscript().run(), isActive: () => editor.isActive('superscript'), }, { type: 'item', - icon: BsSubscript, + icon: MdSubscript, title: 'Subscript', action: () => editor.chain().focus().toggleSubscript().run(), isActive: () => editor.isActive('subscript'), @@ -179,7 +222,7 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { icon: BiListOl, title: 'Ordered list', action: () => editor.chain().focus().toggleOrderedList().run(), - isActive: () => editor.isActive('orderedlist'), + isActive: () => editor.isActive('orderedList'), }, { @@ -187,7 +230,7 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { icon: BiListUl, title: 'Bullet list', action: () => editor.chain().focus().toggleBulletList().run(), - isActive: () => editor.isActive('unorderedlist'), + isActive: () => editor.isActive('bulletList'), }, ], }, @@ -195,16 +238,10 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { type: 'divider', }, // { - // type: "item", + // type: 'item', // icon: BiLink, - // title: "Add link", - // action: () => showModal("hyperlink"), - // }, - // { - // type: "item", - // icon: BiImageAdd, - // title: "Add image", - // action: () => showModal("images"), + // title: 'Add link', + // action: () => showModal('hyperlink'), // }, { type: 'item', @@ -219,33 +256,14 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { .run(), }, // { - // type: "item", + // type: 'item', // icon: BiFile, - // title: "Add file", - // action: () => showModal("files"), - // }, - // { - // type: "item", - // icon: BiCodeAlt, - // title: "Insert embed", - // action: () => showModal("embed"), + // title: 'Add file', + // action: () => showModal('files'), // }, { type: 'divider', }, - { - type: 'detailed-list', - label: 'Add complex blocks', - icon: BiPlus, - items: Object.keys(schema.components.complex).map((component) => ({ - name: `${component.charAt(0).toUpperCase()}${component.slice(1)}`, - action: () => - editor.chain().focus().createComplexComponent(component).run(), - })), - }, - { - type: 'divider', - }, { type: 'item', icon: BiUndo, @@ -261,22 +279,92 @@ const MenuBar = ({ editor, schema }: { editor: Editor; schema: any }) => { ] return ( - + {items.map((item) => ( <> {item.type === 'divider' && !item.isHidden && ( - + )} - {/* {item.type === "horizontal-list" && ( + {item.type === 'vertical-list' && ( + + {({ isOpen }) => { + const activeItem = item.items.find( + (subItem) => subItem.isActive && subItem.isActive(), + ) + + return ( + <> + + {activeItem?.title || item.defaultTitle} + + + + {item.items.map((subItem) => ( + + {subItem.leftItem} + {subItem.title && !subItem.icon && ( + + {subItem.title} + + )} + {subItem.icon && ( + + )} + + ))} + + + ) + }} + + )} + + {item.type === 'horizontal-list' && ( {({ isOpen }) => ( <> - ) + ), )} - )} */} - - {item.type === 'item' && ( - )} + + {item.type === 'item' && } ))} - + ) } - -export default MenuBar diff --git a/apps/studio/src/components/PageEditor/MenuItem.tsx b/apps/studio/src/components/PageEditor/MenuItem.tsx new file mode 100644 index 000000000..3ba7c1c8a --- /dev/null +++ b/apps/studio/src/components/PageEditor/MenuItem.tsx @@ -0,0 +1,69 @@ +import { Icon, Tooltip, Divider } from '@chakra-ui/react' +import { IconButton } from '@opengovsg/design-system-react' +import { MouseEventHandler } from 'react' +import { IconType } from 'react-icons/lib' + +interface MenuItemProps { + icon?: IconType + title?: string + action?: MouseEventHandler + isActive?: null | (() => boolean) + isRound?: boolean + color?: string + type?: string +} + +export const MenuItem = ({ + icon, + title, + action, + isRound, + isActive = null, + color = '', + type = 'item', +}: MenuItemProps) => ( + + {type === 'divider' ? ( + + + + ) : ( + + + + )} + +) diff --git a/apps/studio/src/contexts/EditorDrawerContext.tsx b/apps/studio/src/contexts/EditorDrawerContext.tsx new file mode 100644 index 000000000..72ab4dea7 --- /dev/null +++ b/apps/studio/src/contexts/EditorDrawerContext.tsx @@ -0,0 +1,44 @@ +import React, { + createContext, + useState, + useContext, + useMemo, + type PropsWithChildren, +} from 'react' +import { type DrawerState } from '~/types/editorDrawer' + +export interface DrawerContextType { + drawerState: DrawerState | null + setDrawerState: (state: DrawerState) => void +} +const EditorDrawerContext = createContext(null) + +export function EditorDrawerProvider({ children }: PropsWithChildren) { + const [drawerState, setDrawerState] = useState(null) + + const value = useMemo( + () => ({ + drawerState, + setDrawerState, + }), + [drawerState, setDrawerState], + ) + + return ( + + {children} + + ) +} + +export const useEditorDrawerContext = () => { + const editorDrawerContext = useContext(EditorDrawerContext) + + if (!editorDrawerContext) { + throw new Error( + 'useEditorDrawer must be used within an EditorDrawerContextProvider', + ) + } + + return editorDrawerContext +} diff --git a/apps/studio/src/features/editing-experience/components/0.1.0.json b/apps/studio/src/features/editing-experience/components/0.1.0.json new file mode 100644 index 000000000..a60902a8e --- /dev/null +++ b/apps/studio/src/features/editing-experience/components/0.1.0.json @@ -0,0 +1,1797 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://schema.isomer.gov.sg/next/0.1.0.json", + "title": "Isomer Next Page JSON", + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "string", + "description": "The version of the Isomer Next schema to use", + "default": "0.1.0" + } + }, + "oneOf": [ + { + "$ref": "#/layouts/homepage" + }, + { + "$ref": "#/layouts/content" + }, + { + "$ref": "#/layouts/collection" + }, + { + "$ref": "#/layouts/article" + }, + { + "$ref": "#/layouts/file" + }, + { + "$ref": "#/layouts/link" + } + ], + "definitions": { + "content-components": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "$ref": "#/components/complex/accordion" + }, + { + "$ref": "#/components/complex/button" + }, + { + "$ref": "#/components/complex/callout" + }, + { + "$ref": "#/components/complex/hero" + }, + { + "$ref": "#/components/complex/iframe" + }, + { + "$ref": "#/components/complex/infobar" + }, + { + "$ref": "#/components/complex/infocards" + }, + { + "$ref": "#/components/complex/infocols" + }, + { + "$ref": "#/components/complex/infopic" + }, + { + "$ref": "#/components/complex/keystatistics" + }, + { + "$ref": "#/components/native/divider" + }, + { + "$ref": "#/components/native/heading" + }, + { + "$ref": "#/components/native/image" + }, + { + "$ref": "#/components/native/orderedlist" + }, + { + "$ref": "#/components/native/paragraph" + }, + { + "$ref": "#/components/native/table" + }, + { + "$ref": "#/components/native/unorderedlist" + } + ] + } + }, + "page-metadata": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "title": "Page title" + }, + "description": { + "type": "string", + "title": "Page description", + "description": "The summary of the page for SEO purposes" + }, + "noIndex": { + "type": "boolean", + "description": "Whether to exclude the page from search results" + } + } + } + }, + "layouts": { + "homepage": { + "type": "object", + "title": "Homepage layout", + "required": ["layout", "page", "content"], + "properties": { + "layout": { + "type": "string", + "enum": ["homepage"], + "default": "homepage" + }, + "page": { + "allOf": [ + { + "$ref": "#/definitions/page-metadata" + } + ] + }, + "content": { + "type": "array", + "title": "Page content", + "$ref": "#/definitions/content-components" + } + } + }, + "content": { + "type": "object", + "title": "Content page layout", + "required": ["layout", "page", "content"], + "properties": { + "layout": { + "type": "string", + "enum": ["content"], + "default": "content" + }, + "page": { + "allOf": [ + { + "$ref": "#/definitions/page-metadata" + }, + { + "type": "object", + "required": ["contentPageHeader"], + "properties": { + "contentPageHeader": { + "$ref": "#/components/internal/contentpageheader" + } + } + } + ] + }, + "content": { + "type": "array", + "title": "Page content", + "$ref": "#/definitions/content-components" + } + } + }, + "collection": { + "type": "object", + "title": "Collection page layout", + "required": ["layout", "page"], + "properties": { + "layout": { + "type": "string", + "enum": ["collection"], + "default": "collection" + }, + "page": { + "allOf": [ + { + "$ref": "#/definitions/page-metadata" + }, + { + "type": "object", + "required": ["defaultSortBy", "defaultSortDirection", "subtitle"], + "properties": { + "defaultSortBy": { + "type": "string", + "description": "The default field to sort the collection items by", + "enum": ["date"] + }, + "defaultSortDirection": { + "type": "string", + "description": "The default direction to sort the collection items by", + "enum": ["asc", "desc"] + }, + "subtitle": { + "type": "string", + "description": "The subtitle of the collection" + } + } + } + ] + }, + "content": { + "type": "array", + "title": "Page content", + "description": "This should be empty for collection pages, make sure to remove any items here.", + "default": [], + "minItems": 0, + "maxItems": 0, + "items": { + "type": "null" + } + } + } + }, + "article": { + "type": "object", + "title": "Article page layout", + "required": ["layout", "page", "content"], + "properties": { + "layout": { + "type": "string", + "enum": ["article"], + "default": "article" + }, + "page": { + "allOf": [ + { + "$ref": "#/definitions/page-metadata" + }, + { + "type": "object", + "required": ["category", "date", "articlePageHeader"], + "properties": { + "category": { + "type": "string", + "title": "Category of the article", + "description": "The category is used for filtering in the parent collection page." + }, + "date": { + "type": "string", + "title": "Date of the article" + }, + "image": { + "$ref": "#/components/internal/image-collection-card" + }, + "articlePageHeader": { + "$ref": "#/components/internal/articlepageheader" + } + } + } + ] + }, + "content": { + "type": "array", + "title": "Page content", + "$ref": "#/definitions/content-components" + } + } + }, + "file": { + "type": "object", + "title": "File reference layout", + "required": ["layout", "page"], + "properties": { + "layout": { + "type": "string", + "enum": ["file"], + "default": "file" + }, + "page": { + "allOf": [ + { + "$ref": "#/definitions/page-metadata" + }, + { + "required": ["ref", "category", "date"], + "properties": { + "ref": { + "type": "string", + "title": "URL of the actual file", + "description": "The link that users will open immediately when they click on the file item in the parent collection page." + }, + "category": { + "type": "string", + "title": "Category of the file item", + "description": "The category is used for filtering in the parent collection page." + }, + "date": { + "type": "string", + "title": "Date of the file item" + }, + "image": { + "$ref": "#/components/internal/image-collection-card" + } + } + } + ] + }, + "content": { + "type": "array", + "title": "Page content", + "description": "This should be empty for file pages, make sure to remove any items here.", + "default": [], + "minItems": 0, + "maxItems": 0, + "items": { + "type": "null" + } + } + } + }, + "link": { + "type": "object", + "title": "Link reference layout", + "required": ["layout", "page"], + "properties": { + "layout": { + "type": "string", + "enum": ["link"], + "default": "link" + }, + "page": { + "allOf": [ + { + "$ref": "#/definitions/page-metadata" + }, + { + "required": ["ref", "category", "date"], + "properties": { + "ref": { + "type": "string", + "title": "URL of the actual link", + "description": "The link that users will open immediately when they click on the link item in the parent collection page." + }, + "category": { + "type": "string", + "title": "Category of the link item", + "description": "The category is used for filtering in the parent collection page." + }, + "date": { + "type": "string", + "title": "Date of the link item" + }, + "image": { + "$ref": "#/components/internal/image-collection-card" + } + } + } + ] + }, + "content": { + "type": "array", + "title": "Page content", + "description": "This should be empty for link pages, make sure to remove any items here.", + "default": [], + "minItems": 0, + "maxItems": 0, + "items": { + "type": "null" + } + } + } + } + }, + "components": { + "complex": { + "accordion": { + "type": "object", + "title": "Accordion component", + "required": ["type", "summary", "details"], + "properties": { + "type": { + "type": "string", + "enum": ["accordion"], + "default": "accordion" + }, + "summary": { + "type": "string", + "title": "Accordion summary", + "description": "The summary for the accordion" + }, + "details": { + "type": "array", + "format": "prose", + "title": "Accordion contents", + "description": "The contents inside the accordion", + "minItems": 1, + "items": { + "anyOf": [ + { + "$ref": "#/components/native/divider" + }, + { + "$ref": "#/components/native/heading" + }, + { + "$ref": "#/components/native/image" + }, + { + "$ref": "#/components/native/orderedlist" + }, + { + "$ref": "#/components/native/paragraph" + }, + { + "$ref": "#/components/native/table" + }, + { + "$ref": "#/components/native/unorderedlist" + } + ] + } + } + } + }, + "button": { + "type": "object", + "title": "Button component", + "required": ["type", "label", "href"], + "properties": { + "type": { + "type": "string", + "enum": ["button"], + "default": "button" + }, + "label": { + "type": "string", + "title": "Button label", + "description": "The text to display on the button" + }, + "href": { + "type": "string", + "title": "Button URL", + "description": "The URL to link the button to" + }, + "colorScheme": { + "type": "string", + "title": "Button color scheme", + "description": "The color scheme of the button to use", + "enum": ["black", "white"] + }, + "variant": { + "type": "string", + "title": "Button variant", + "description": "The variant of the button to use", + "enum": ["link", "solid", "outline", "ghost"] + }, + "rounded": { + "type": "boolean", + "title": "Button rounded", + "description": "Whether to have the button corners be rounded" + }, + "leftIcon": { + "type": "string", + "title": "Button left icon", + "description": "The icon to display on the left of the button's text", + "enum": ["right-arrow", "bar-chart"] + }, + "rightIcon": { + "type": "string", + "title": "Button right icon", + "description": "The icon to display on the right of the button's text", + "enum": ["right-arrow", "bar-chart"] + } + } + }, + "callout": { + "type": "object", + "title": "Callout component", + "required": ["type", "content", "variant"], + "properties": { + "type": { + "type": "string", + "enum": ["callout"], + "default": "callout" + }, + "variant": { + "type": "string", + "title": "Callout variant", + "description": "The variant of the callout to use", + "format": "radio", + "enum": ["info", "success", "warning", "critical"] + }, + "content": { + "type": "array", + "format": "prose", + "title": "Callout content", + "description": "The content to display in the callout", + "minItems": 1, + "items": { + "anyOf": [ + { + "$ref": "#/components/native/divider" + }, + { + "$ref": "#/components/native/heading" + }, + { + "$ref": "#/components/native/image" + }, + { + "$ref": "#/components/native/orderedlist" + }, + { + "$ref": "#/components/native/paragraph" + }, + { + "$ref": "#/components/native/table" + }, + { + "$ref": "#/components/native/unorderedlist" + } + ] + } + } + } + }, + "hero": { + "type": "object", + "title": "Hero component", + "description": "The hero component is used to display a large banner at the top of the homepage.", + "required": ["type", "variant"], + "properties": { + "type": { + "type": "string", + "enum": ["hero"], + "default": "hero" + } + }, + "oneOf": [ + { + "$ref": "#/components/internal/hero-side" + }, + { + "$ref": "#/components/internal/hero-image" + }, + { + "$ref": "#/components/internal/hero-floating" + }, + { + "$ref": "#/components/internal/hero-center" + }, + { + "$ref": "#/components/internal/hero-gradient" + }, + { + "$ref": "#/components/internal/hero-split" + }, + { + "$ref": "#/components/internal/hero-copyled" + }, + { + "$ref": "#/components/internal/hero-floatingimage" + } + ] + }, + "iframe": { + "type": "object", + "title": "Iframe component", + "description": "The iframe component is used to embed a whitelisted external webpage within the current page.", + "required": ["type", "title", "content"], + "properties": { + "type": { + "type": "string", + "enum": ["iframe"], + "default": "iframe" + }, + "title": { + "type": "string", + "title": "Iframe title", + "description": "The title of the iframe" + }, + "content": { + "type": "string", + "title": "Iframe content", + "description": "The full iframe embed code to display, should only contain the