Skip to content

Commit 4e4f373

Browse files
authored
feat: support editing map and video embeds (#885)
* feat: support editing map and video embeds * chore: update based on feedback * chore: add map and video embeds in storybook
1 parent 3ae4ee5 commit 4e4f373

File tree

9 files changed

+321
-11
lines changed

9 files changed

+321
-11
lines changed

apps/studio/next.config.mjs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ const ContentSecurityPolicy = `
3636
frame-src
3737
'self'
3838
https://intercom-sheets.com
39-
https://www.intercom-reporting.com
40-
https://www.youtube.com
39+
https://www.intercom-reporting.com
4140
https://player.vimeo.com
4241
https://fast.wistia.net
42+
https://www.google.com
43+
https://www.youtube.com
44+
https://www.youtube-nocookie.com
45+
https://www.onemap.gov.sg
46+
https://www.facebook.com
4347
;
4448
object-src 'none';
4549
script-src
@@ -63,7 +67,6 @@ const ContentSecurityPolicy = `
6367
;
6468
connect-src
6569
'self'
66-
https://schema.isomer.gov.sg
6770
https://browser-intake-datadoghq.com
6871
https://*.browser-intake-datadoghq.com
6972
https://vitals.vercel-insights.com
@@ -78,27 +81,27 @@ const ContentSecurityPolicy = `
7881
https://api.eu.intercom.io
7982
https://api-iam.intercom.io
8083
https://api-iam.eu.intercom.io
81-
https://api-iam.au.intercom.io
82-
https://api-ping.intercom.io
84+
https://api-iam.au.intercom.io
85+
https://api-ping.intercom.io
8386
https://nexus-websocket-a.intercom.io
8487
wss://nexus-websocket-a.intercom.io
8588
https://nexus-websocket-b.intercom.io
8689
wss://nexus-websocket-b.intercom.io
87-
https://nexus-europe-websocket.intercom.io
88-
wss://nexus-europe-websocket.intercom.io
90+
https://nexus-europe-websocket.intercom.io
91+
wss://nexus-europe-websocket.intercom.io
8992
https://nexus-australia-websocket.intercom.io
90-
wss://nexus-australia-websocket.intercom.io
93+
wss://nexus-australia-websocket.intercom.io
9194
https://uploads.intercomcdn.com
92-
https://uploads.intercomcdn.eu
93-
https://uploads.au.intercomcdn.com
95+
https://uploads.intercomcdn.eu
96+
https://uploads.au.intercomcdn.com
9497
https://uploads.eu.intercomcdn.com
9598
https://uploads.intercomusercontent.com
9699
;
97100
worker-src
98101
'self'
99102
blob:
100103
https://intercom-sheets.com
101-
https://www.intercom-reporting.com
104+
https://www.intercom-reporting.com
102105
https://www.youtube.com
103106
https://player.vimeo.com
104107
https://fast.wistia.net

apps/studio/src/components/PageEditor/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ type AllowedBlockSections = {
279279

280280
export const ARTICLE_ALLOWED_BLOCKS: AllowedBlockSections = [
281281
{ label: "Basic building blocks", types: ["prose", "image", "callout"] },
282+
{ label: "Embed external content", types: ["map", "video"] },
282283
]
283284

284285
export const CONTENT_ALLOWED_BLOCKS: AllowedBlockSections = [
@@ -287,6 +288,7 @@ export const CONTENT_ALLOWED_BLOCKS: AllowedBlockSections = [
287288
label: "Organise complex content",
288289
types: ["contentpic", "infocards", "accordion", "infocols"],
289290
},
291+
{ label: "Embed external content", types: ["map", "video"] },
290292
]
291293
export const HOMEPAGE_ALLOWED_BLOCKS: AllowedBlockSections = [
292294
{

apps/studio/src/features/editing-experience/components/form-builder/FormBuilder.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
jsonFormsConstControlTester,
2626
JsonFormsDateControl,
2727
jsonFormsDateControlTester,
28+
JsonFormsEmbedControl,
29+
jsonFormsEmbedControlTester,
2830
jsonFormsGroupLayoutRenderer,
2931
jsonFormsGroupLayoutTester,
3032
JsonFormsImageControl,
@@ -57,6 +59,7 @@ const renderers: JsonFormsRendererRegistryEntry[] = [
5759
{ tester: jsonFormsArrayControlTester, renderer: JsonFormsArrayControl },
5860
{ tester: jsonFormsBooleanControlTester, renderer: JsonFormsBooleanControl },
5961
{ tester: jsonFormsConstControlTester, renderer: JsonFormsConstControl },
62+
{ tester: jsonFormsEmbedControlTester, renderer: JsonFormsEmbedControl },
6063
{ tester: jsonFormsIntegerControlTester, renderer: JsonFormsIntegerControl },
6164
{ tester: jsonFormsImageControlTester, renderer: JsonFormsImageControl },
6265
{ tester: jsonFormsLinkControlTester, renderer: JsonFormsLinkControl },
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import type { ControlProps, RankedTester } from "@jsonforms/core"
2+
import {
3+
Box,
4+
FormControl,
5+
HStack,
6+
Icon,
7+
ListItem,
8+
Modal,
9+
ModalBody,
10+
ModalContent,
11+
ModalFooter,
12+
ModalHeader,
13+
ModalOverlay,
14+
Text,
15+
UnorderedList,
16+
useDisclosure,
17+
VStack,
18+
} from "@chakra-ui/react"
19+
import { and, isStringControl, rankWith, schemaMatches } from "@jsonforms/core"
20+
import { withJsonFormsControlProps } from "@jsonforms/react"
21+
import {
22+
Button,
23+
FormErrorMessage,
24+
FormLabel,
25+
Infobox,
26+
ModalCloseButton,
27+
Textarea,
28+
} from "@opengovsg/design-system-react"
29+
import {
30+
MAPS_EMBED_URL_REGEXES,
31+
VIDEO_EMBED_URL_REGEXES,
32+
} from "@opengovsg/isomer-components"
33+
import { BiLink } from "react-icons/bi"
34+
import { z } from "zod"
35+
36+
import { JSON_FORMS_RANKING } from "~/constants/formBuilder"
37+
import { useZodForm } from "~/lib/form"
38+
import {
39+
EMBED_NAME_MAPPING,
40+
getEmbedNameFromUrl,
41+
getIframeSrc,
42+
} from "../../../utils"
43+
44+
const SUPPORTED_MAPS = Object.keys(MAPS_EMBED_URL_REGEXES).map(
45+
(key) => EMBED_NAME_MAPPING[key as keyof typeof MAPS_EMBED_URL_REGEXES],
46+
)
47+
48+
const SUPPORTED_VIDEOS = Object.keys(VIDEO_EMBED_URL_REGEXES).map(
49+
(key) => EMBED_NAME_MAPPING[key as keyof typeof VIDEO_EMBED_URL_REGEXES],
50+
)
51+
52+
export const jsonFormsEmbedControlTester: RankedTester = rankWith(
53+
JSON_FORMS_RANKING.TextControl,
54+
and(
55+
isStringControl,
56+
schemaMatches((schema) => schema.format === "embed"),
57+
),
58+
)
59+
60+
interface EmbedCodeModalProps {
61+
isOpen: boolean
62+
onClose: () => void
63+
onSave: (embedCode: string) => void
64+
urlPattern?: string
65+
}
66+
67+
function EmbedCodeModal({
68+
isOpen,
69+
onClose,
70+
onSave,
71+
urlPattern,
72+
}: EmbedCodeModalProps) {
73+
const {
74+
register,
75+
handleSubmit,
76+
reset,
77+
formState: { errors, isValid },
78+
} = useZodForm({
79+
mode: "onChange",
80+
schema: z.object({
81+
embedCode: z
82+
.string()
83+
.min(1, "Embed code is required")
84+
.refine(
85+
(value) => {
86+
const iframeSrc = getIframeSrc(value)
87+
88+
if (!urlPattern || !iframeSrc) {
89+
return !!iframeSrc
90+
}
91+
92+
return new RegExp(urlPattern).test(iframeSrc)
93+
},
94+
{
95+
message:
96+
"This code doesn’t look valid. Copy and paste the code as-is without modifications.",
97+
},
98+
),
99+
}),
100+
reValidateMode: "onChange",
101+
})
102+
103+
const onSubmit = handleSubmit(({ embedCode }) => {
104+
onClose()
105+
onSave(embedCode)
106+
reset()
107+
})
108+
109+
return (
110+
<Modal isOpen={isOpen} onClose={onClose}>
111+
<ModalOverlay />
112+
<ModalContent>
113+
<form onSubmit={onSubmit}>
114+
<ModalHeader mr="3.5rem">Insert embed code</ModalHeader>
115+
<ModalCloseButton size="lg" />
116+
117+
<ModalBody>
118+
<FormControl mt="1rem" isRequired isInvalid={!!errors.embedCode}>
119+
<FormLabel description="The template may override settings like width and height of the iframe">
120+
Embed code
121+
</FormLabel>
122+
123+
<Textarea
124+
placeholder="Paste embed code here"
125+
fontFamily="monospace"
126+
minAutosizeRows={5}
127+
{...register("embedCode")}
128+
/>
129+
130+
<FormErrorMessage>{errors.embedCode?.message}</FormErrorMessage>
131+
</FormControl>
132+
133+
<Infobox size="sm" variant="info" mt="1.25rem">
134+
<Box>
135+
<Text>You can embed content from:</Text>
136+
<UnorderedList ml="1.5rem" mt="0.25rem">
137+
<ListItem>Video: {SUPPORTED_VIDEOS.join(", ")}</ListItem>
138+
<ListItem>Map: {SUPPORTED_MAPS.join(", ")}</ListItem>
139+
</UnorderedList>
140+
</Box>
141+
</Infobox>
142+
</ModalBody>
143+
144+
<ModalFooter>
145+
<HStack spacing="0.75rem">
146+
<Button
147+
variant="clear"
148+
color="base.content.default"
149+
onClick={onClose}
150+
>
151+
Cancel
152+
</Button>
153+
<Button type="submit" onClick={onSubmit} isDisabled={!isValid}>
154+
Save code
155+
</Button>
156+
</HStack>
157+
</ModalFooter>
158+
</form>
159+
</ModalContent>
160+
</Modal>
161+
)
162+
}
163+
164+
export function JsonFormsEmbedControl({
165+
data,
166+
label,
167+
handleChange,
168+
path,
169+
description,
170+
required,
171+
errors,
172+
schema,
173+
}: ControlProps) {
174+
const {
175+
isOpen: isEmbedModalOpen,
176+
onOpen: onEmbedModalOpen,
177+
onClose: onEmbedModalClose,
178+
} = useDisclosure()
179+
180+
const handleEmbedCodeSave = (embedCode: string) => {
181+
const src = getIframeSrc(embedCode)
182+
handleChange(path, src)
183+
}
184+
185+
return (
186+
<>
187+
<EmbedCodeModal
188+
isOpen={isEmbedModalOpen}
189+
onClose={onEmbedModalClose}
190+
onSave={(embedCode) => handleEmbedCodeSave(embedCode)}
191+
urlPattern={schema.pattern}
192+
/>
193+
194+
<Box>
195+
<FormControl isRequired={required} isInvalid={!!errors}>
196+
<FormLabel description={description}>{label}</FormLabel>
197+
198+
<HStack
199+
justifyContent="space-between"
200+
p="1rem"
201+
bgColor="utility.ui"
202+
borderRadius="0.25rem"
203+
borderWidth="1px"
204+
borderStyle="solid"
205+
borderColor="base.divider.medium"
206+
>
207+
<VStack gap="0.25rem" align="start">
208+
<HStack gap="0.25rem">
209+
<Icon as={BiLink} />
210+
<Text textStyle="caption-1">
211+
{getEmbedNameFromUrl(String(data || "")) ??
212+
"External content"}
213+
</Text>
214+
</HStack>
215+
216+
<Box>
217+
<Text
218+
noOfLines={1}
219+
wordBreak="break-all"
220+
textStyle="caption-2"
221+
color="base.content.medium"
222+
>
223+
{String(data || "")}
224+
</Text>
225+
</Box>
226+
</VStack>
227+
228+
<Button variant="clear" onClick={onEmbedModalOpen}>
229+
Edit
230+
</Button>
231+
</HStack>
232+
233+
<FormErrorMessage>
234+
{label} {errors}
235+
</FormErrorMessage>
236+
</FormControl>
237+
</Box>
238+
</>
239+
)
240+
}
241+
242+
export default withJsonFormsControlProps(JsonFormsEmbedControl)

apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,7 @@ export {
6262
default as JsonFormsRefControl,
6363
jsonFormsRefControlTester,
6464
} from "./JsonFormsRefControl"
65+
export {
66+
default as JsonFormsEmbedControl,
67+
jsonFormsEmbedControlTester,
68+
} from "./JsonFormsEmbedControl"

0 commit comments

Comments
 (0)