Skip to content

Commit 60d4cd3

Browse files
Feat/studio highlight invalid block (#1554)
* feat: add isInvalid prop to BaseBlock for conditional styling * feat: add layout prop to DraggableBlock for validation logic * feat: implement FixedBlock component for improved block rendering in RootStateDrawer * fixed: use savedPageState instead * refactor: optimize memo * refactor: move ajv compilation into layout-level to make it less expensive * chore: add comments * refactor: optimize hero component validation in RootStateDrawer * feat: add schema caching utility for optimized AJV schema compilation * refactor: simplify schema cache retrieval logic in getCachedScopedSchema function * feat: integrate component schema definitions into scoped schema retrieval * refactor: update validation logic in RootStateDrawer to use cached schema for improved performance * refactor: enhance cache key generation in getCachedScopedSchema function for improved clarity * refactor: optimize invalid block index validation in RootStateDrawer using useMemo for better performance * refactor: improve styling and accessibility for BaseBlock component with enhanced invalid state handling * fix type * fix lint * fix lint * fix: ensure validateFn is called without awaiting in RootStateDrawer for lint compliance * fix: update background color for invalid state in BaseBlock component for better visual feedback * refactor: enhance invalid state handling in BaseBlock and DraggableBlock components, adding descriptive feedback for improved user experience * chore: remove console.log * revert from scopedschema * Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * refactor: improve error handling in RootStateDrawer by using regex for content index extraction --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent 8b041dc commit 60d4cd3

File tree

4 files changed

+159
-64
lines changed

4 files changed

+159
-64
lines changed

apps/studio/src/features/editing-experience/components/Block/BaseBlock.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import type { ButtonProps, StackProps } from "@chakra-ui/react"
22
import type { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"
33
import type { IconType } from "react-icons"
44
import { chakra, Flex, HStack, Icon, Stack, Text } from "@chakra-ui/react"
5-
import { BiGridVertical } from "react-icons/bi"
5+
import { BiGridVertical, BiSolidErrorCircle } from "react-icons/bi"
66

7-
interface BaseBlockProps {
7+
export interface BaseBlockProps {
88
icon?: IconType
99
dragHandle?: React.ReactNode
1010
label: string
1111
description?: string
1212
containerProps?: StackProps
1313
onClick?: () => void
1414
draggableProps?: DraggableProvidedDragHandleProps | null
15+
invalidProps?: {
16+
description: string
17+
}
1518
}
1619

1720
export const BaseBlock = ({
@@ -22,9 +25,42 @@ export const BaseBlock = ({
2225
draggableProps,
2326
containerProps,
2427
onClick,
28+
invalidProps,
2529
}: BaseBlockProps): JSX.Element => {
2630
const actualDraggableProps = draggableProps ?? {}
2731

32+
const Description = () => {
33+
if (invalidProps) {
34+
return (
35+
<HStack gap="0.25rem">
36+
<Icon
37+
as={BiSolidErrorCircle}
38+
fontSize="1rem"
39+
color="utility.feedback.critical"
40+
/>
41+
<Text textStyle="caption-1" color="utility.feedback.critical">
42+
{invalidProps.description}
43+
</Text>
44+
</HStack>
45+
)
46+
}
47+
48+
if (description) {
49+
return (
50+
<Text
51+
textStyle="caption-2"
52+
color={
53+
dragHandle
54+
? "interaction.support.placeholder"
55+
: "base.content.default"
56+
}
57+
>
58+
{description}
59+
</Text>
60+
)
61+
}
62+
}
63+
2864
return (
2965
<HStack
3066
as="button"
@@ -35,15 +71,23 @@ export const BaseBlock = ({
3571
borderColor="base.divider.medium"
3672
transitionProperty="common"
3773
transitionDuration="normal"
74+
aria-invalid={!!invalidProps}
3875
_hover={{
3976
bg: "interaction.muted.main.hover",
4077
borderColor: "interaction.main-subtle.hover",
78+
_invalid: {
79+
shadow: "0px 1px 6px 0px #C0343426",
80+
},
4181
}}
4282
_active={{
4383
bg: "interaction.main-subtle.default",
4484
borderColor: "interaction.main-subtle.hover",
4585
shadow: "0px 1px 6px 0px #1361F026",
4686
}}
87+
_invalid={{
88+
bg: "utility.feedback.critical-subtle",
89+
borderColor: "utility.feedback.critical",
90+
}}
4791
bg="white"
4892
py="0.75rem"
4993
px="0.75rem"
@@ -69,18 +113,7 @@ export const BaseBlock = ({
69113
<Text textStyle="subhead-2" noOfLines={1} wordBreak="break-word">
70114
{label}
71115
</Text>
72-
{description && (
73-
<Text
74-
textStyle="caption-2"
75-
color={
76-
dragHandle
77-
? "interaction.support.placeholder"
78-
: "base.content.default"
79-
}
80-
>
81-
{description}
82-
</Text>
83-
)}
116+
<Description />
84117
</Stack>
85118
</HStack>
86119
)

apps/studio/src/features/editing-experience/components/Block/DraggableBlock.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import {
99

1010
import { PROSE_COMPONENT_NAME } from "~/constants/formBuilder"
1111
import { TYPE_TO_ICON } from "../../constants"
12-
import { BaseBlock, BaseBlockDragHandle } from "./BaseBlock"
12+
import { BaseBlock, BaseBlockDragHandle, BaseBlockProps } from "./BaseBlock"
1313

14-
interface DraggableBlockProps {
14+
interface DraggableBlockProps extends Pick<BaseBlockProps, "invalidProps"> {
1515
block: IsomerSchema["content"][number]
1616
draggableId: string
1717
index: number
@@ -23,6 +23,7 @@ export const DraggableBlock = ({
2323
draggableId,
2424
index,
2525
onClick,
26+
invalidProps,
2627
}: DraggableBlockProps): JSX.Element => {
2728
const icon = TYPE_TO_ICON[block.type]
2829

@@ -70,6 +71,7 @@ export const DraggableBlock = ({
7071
}
7172
description={blockComponentName}
7273
icon={icon}
74+
invalidProps={invalidProps}
7375
/>
7476
</VStack>
7577
)

apps/studio/src/features/editing-experience/components/RootStateDrawer.tsx

Lines changed: 102 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { DropResult } from "@hello-pangea/dnd"
2+
import type {
3+
IsomerComponent,
4+
IsomerSchema,
5+
} from "@opengovsg/isomer-components"
26
import { useCallback, useState } from "react"
37
import {
48
Box,
@@ -11,7 +15,11 @@ import {
1115
} from "@chakra-ui/react"
1216
import { DragDropContext, Droppable } from "@hello-pangea/dnd"
1317
import { Infobox, useToast } from "@opengovsg/design-system-react"
14-
import { ISOMER_USABLE_PAGE_LAYOUTS } from "@opengovsg/isomer-components"
18+
import {
19+
getComponentSchema,
20+
ISOMER_USABLE_PAGE_LAYOUTS,
21+
schema,
22+
} from "@opengovsg/isomer-components"
1523
import { ResourceType } from "~prisma/generated/generatedEnums"
1624
import { BiData, BiPin, BiPlus, BiPlusCircle } from "react-icons/bi"
1725

@@ -23,6 +31,7 @@ import { useEditorDrawerContext } from "~/contexts/EditorDrawerContext"
2331
import { useIsUserIsomerAdmin } from "~/hooks/useIsUserIsomerAdmin"
2432
import { useQueryParse } from "~/hooks/useQueryParse"
2533
import { ADMIN_ROLE } from "~/lib/growthbook"
34+
import { ajv } from "~/utils/ajv"
2635
import { trpc } from "~/utils/trpc"
2736
import { TYPE_TO_ICON } from "../constants"
2837
import { pageSchema } from "../schema"
@@ -56,6 +65,14 @@ const FIXED_BLOCK_CONTENT: Record<string, FixedBlockContent> = {
5665
},
5766
}
5867

68+
const validateHeroComponentFn = ajv.compile<IsomerComponent>(
69+
getComponentSchema({ component: "hero" }),
70+
)
71+
72+
const validateFn = ajv.compile<IsomerSchema>(schema)
73+
74+
const invalidBlockDescription = "Fix errors in this block to publish"
75+
5976
export default function RootStateDrawer() {
6077
const {
6178
type,
@@ -231,6 +248,81 @@ export default function RootStateDrawer() {
231248
pageLayout !== "index" &&
232249
pageLayout !== "collection"
233250

251+
validateFn(savedPageState)
252+
253+
const contentIndexRegex = /^\/content\/(\d+)/
254+
const invalidBlockIndexes = new Set(
255+
(validateFn.errors ?? [])
256+
// When validating content array directly,
257+
// instancePath will be like "content/0", "content/1", "content/2", etc.
258+
// where the number is the index of the invalid component
259+
.map((e) => contentIndexRegex.exec(e.instancePath)?.[1])
260+
.filter(Boolean)
261+
.map(Number),
262+
)
263+
264+
const FixedBlock = () => {
265+
// Assuming only one fixedBlock can exist at a time for now
266+
const fixedBlock = savedPageState.content[0]
267+
268+
if (isHeroFixedBlock) {
269+
const isValid = validateHeroComponentFn(fixedBlock)
270+
return (
271+
<BaseBlock
272+
onClick={() => {
273+
setCurrActiveIdx(0)
274+
setDrawerState({ state: "heroEditor" })
275+
}}
276+
label="Hero banner"
277+
description="Title, subtitle, and Call-to-Action"
278+
icon={TYPE_TO_ICON.hero}
279+
invalidProps={
280+
!isValid ? { description: invalidBlockDescription } : undefined
281+
}
282+
/>
283+
)
284+
}
285+
286+
if (pageLayout === ISOMER_USABLE_PAGE_LAYOUTS.Database) {
287+
return (
288+
<VStack gap="1rem" w="100%" align="start">
289+
<BaseBlock
290+
onClick={() => {
291+
setDrawerState({ state: "metadataEditor" })
292+
}}
293+
label="Page header"
294+
description="Summary, Button label, and Button URL"
295+
icon={BiPin}
296+
/>
297+
<BaseBlock
298+
onClick={() => {
299+
setDrawerState({ state: "databaseEditor" })
300+
}}
301+
label="Database"
302+
description="Link your dataset from Data.gov.sg"
303+
icon={BiData}
304+
/>
305+
</VStack>
306+
)
307+
}
308+
309+
return (
310+
<BaseBlock
311+
onClick={() => {
312+
setDrawerState({ state: "metadataEditor" })
313+
}}
314+
label={
315+
FIXED_BLOCK_CONTENT[pageLayout]?.label ||
316+
"Page description and summary"
317+
}
318+
description={
319+
FIXED_BLOCK_CONTENT[pageLayout]?.description || "Click to edit"
320+
}
321+
icon={BiPin}
322+
/>
323+
)
324+
}
325+
234326
// NOTE: if a page has either of these `layouts`,
235327
// we should disable them from adding blocks
236328
// because folder index pages aren't intended to have
@@ -322,52 +414,7 @@ export default function RootStateDrawer() {
322414
deleted
323415
</Text>
324416
</VStack>
325-
326-
{isHeroFixedBlock ? (
327-
<BaseBlock
328-
onClick={() => {
329-
setCurrActiveIdx(0)
330-
setDrawerState({ state: "heroEditor" })
331-
}}
332-
label="Hero banner"
333-
description="Title, subtitle, and Call-to-Action"
334-
icon={TYPE_TO_ICON.hero}
335-
/>
336-
) : pageLayout === ISOMER_USABLE_PAGE_LAYOUTS.Database ? (
337-
<VStack gap="1rem" w="100%" align="start">
338-
<BaseBlock
339-
onClick={() => {
340-
setDrawerState({ state: "metadataEditor" })
341-
}}
342-
label="Page header"
343-
description="Summary, Button label, and Button URL"
344-
icon={BiPin}
345-
/>
346-
<BaseBlock
347-
onClick={() => {
348-
setDrawerState({ state: "databaseEditor" })
349-
}}
350-
label="Database"
351-
description="Link your dataset from Data.gov.sg"
352-
icon={BiData}
353-
/>
354-
</VStack>
355-
) : (
356-
<BaseBlock
357-
onClick={() => {
358-
setDrawerState({ state: "metadataEditor" })
359-
}}
360-
label={
361-
FIXED_BLOCK_CONTENT[pageLayout]?.label ||
362-
"Page description and summary"
363-
}
364-
description={
365-
FIXED_BLOCK_CONTENT[pageLayout]?.description ||
366-
"Click to edit"
367-
}
368-
icon={BiPin}
369-
/>
370-
)}
417+
<FixedBlock />
371418
</VStack>
372419

373420
<VStack gap="1.5rem" w="100%">
@@ -478,6 +525,14 @@ export default function RootStateDrawer() {
478525
// NOTE: SNAPSHOT
479526
setDrawerState({ state: nextState })
480527
}}
528+
invalidProps={
529+
invalidBlockIndexes.has(index)
530+
? {
531+
description:
532+
invalidBlockDescription,
533+
}
534+
: undefined
535+
}
481536
/>
482537
)
483538
})}

packages/components/src/schemas/scopedSchema.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
IndexPageSchema,
1212
LinkRefSchema,
1313
} from "../types/schema"
14+
import { componentSchemaDefinitions } from "./components"
1415

1516
type ScopedSchemaLayout =
1617
(typeof ISOMER_USABLE_PAGE_LAYOUTS)[keyof typeof ISOMER_USABLE_PAGE_LAYOUTS]
@@ -111,9 +112,13 @@ export function getScopedSchema<T extends ScopedSchemaLayout>({
111112

112113
return {
113114
...currentSchema,
115+
...componentSchemaDefinitions,
114116
properties: filteredProperties,
115117
} as TSchema
116118
}
117119

118-
return currentSchema
120+
return {
121+
...currentSchema,
122+
...componentSchemaDefinitions,
123+
} as TSchema
119124
}

0 commit comments

Comments
 (0)