diff --git a/apps/studio/src/components/PageEditor/LinkEditorModal.tsx b/apps/studio/src/components/PageEditor/LinkEditorModal.tsx index 42dfac430..87a95840e 100644 --- a/apps/studio/src/components/PageEditor/LinkEditorModal.tsx +++ b/apps/studio/src/components/PageEditor/LinkEditorModal.tsx @@ -1,4 +1,4 @@ -import { useParams } from "next/navigation" +import type { IconType } from "react-icons" import { Box, FormControl, @@ -20,8 +20,12 @@ import { import { isEmpty } from "lodash" import { z } from "zod" -import type { LinkTypeMapping } from "~/features/editing-experience/components/LinkEditor/constants" +import type { LinkTypes } from "~/features/editing-experience/components/LinkEditor/constants" import { LinkHrefEditor } from "~/features/editing-experience/components/LinkEditor" +import { + LinkEditorContextProvider, + useLinkEditor, +} from "~/features/editing-experience/components/LinkEditor/LinkEditorContext" import { useQueryParse } from "~/hooks/useQueryParse" import { useZodForm } from "~/lib/form" import { getReferenceLink, getResourceIdFromReferenceLink } from "~/utils/link" @@ -33,6 +37,10 @@ const editSiteSchema = z.object({ siteId: z.coerce.number(), }) +const linkSchema = z.object({ + linkId: z.coerce.string().optional(), +}) + interface PageLinkElementProps { value: string onChange: (value: string) => void @@ -68,12 +76,10 @@ const PageLinkElement = ({ value, onChange }: PageLinkElementProps) => { ) } -interface LinkEditorModalContentProps { - linkText?: string - linkHref?: string - onSave: (linkText: string, linkHref: string) => void - linkTypes: LinkTypeMapping -} +type LinkEditorModalContentProps = Pick< + LinkEditorModalProps, + "linkText" | "linkHref" | "linkTypes" | "onSave" +> const LinkEditorModalContent = ({ linkText, @@ -84,7 +90,6 @@ const LinkEditorModalContent = ({ const { handleSubmit, setValue, - watch, register, formState: { errors }, } = useZodForm({ @@ -99,7 +104,7 @@ const LinkEditorModalContent = ({ linkText, linkHref, }, - reValidateMode: "onBlur", + reValidateMode: "onChange", }) const isEditingLink = !!linkText && !!linkHref @@ -110,13 +115,12 @@ const LinkEditorModalContent = ({ ({ linkText, linkHref }) => !!linkHref && onSave(linkText, linkHref), ) - const { siteId } = useQueryParse(editSiteSchema) // TODO: This needs to be refactored urgently // This is a hacky way of seeing what to render // and ties the link editor to the url path. // we should instead just pass the component directly rather than using slots - const { linkId } = useParams() + const { linkId } = useQueryParse(linkSchema) return ( @@ -153,33 +157,20 @@ const LinkEditorModalContent = ({ )} - setValue("linkHref", value)} - label="Link destination" - description="When this is clicked, open:" - isRequired - isInvalid={!!errors.linkHref} - pageLinkElement={ - setValue("linkHref", value)} - /> - } - fileLinkElement={ - { - setValue("linkHref", linkHref) - }} - /> - } - /> - - {errors.linkHref?.message && ( - {errors.linkHref.message} - )} + linkHref={linkHref ?? ""} + onChange={(href) => setValue("linkHref", href)} + error={errors.linkHref?.message} + > + setValue("linkHref", value)} + /> + + {errors.linkHref?.message && ( + {errors.linkHref.message} + )} + @@ -206,7 +197,13 @@ interface LinkEditorModalProps { onSave: (linkText: string, linkHref: string) => void isOpen: boolean onClose: () => void - linkTypes: LinkTypeMapping + linkTypes: Record< + string, + { + icon: IconType + label: Capitalize + } + > } export const LinkEditorModal = ({ isOpen, @@ -232,3 +229,34 @@ export const LinkEditorModal = ({ )} ) + +const ModalLinkEditor = ({ + onChange, +}: { + onChange: (value: string) => void +}) => { + const { error, curHref, setHref } = useLinkEditor() + const { siteId } = useQueryParse(editSiteSchema) + const handleChange = (value: string) => { + onChange(value) + setHref(value) + } + + return ( + + } + fileLinkElement={ + handleChange(href ?? "")} + /> + } + /> + ) +} diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkEditorContext.tsx b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkEditorContext.tsx new file mode 100644 index 000000000..0195dccf2 --- /dev/null +++ b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkEditorContext.tsx @@ -0,0 +1,63 @@ +import type { PropsWithChildren } from "react" +import { createContext, useContext, useState } from "react" + +import type { + LinkTypeMapping, + LinkTypes, +} from "~/features/editing-experience/components/LinkEditor/constants" +import { LINK_TYPES } from "~/features/editing-experience/components/LinkEditor/constants" + +export type LinkEditorContextReturn = ReturnType +const LinkEditorContext = createContext( + undefined, +) + +interface UseLinkEditorContextProps { + linkHref: string + linkTypes: Partial + error?: string + onChange: (value: string) => void +} +const useLinkEditorContext = ({ + linkHref, + linkTypes, + error, + onChange, +}: UseLinkEditorContextProps) => { + const [curType, setCurType] = useState(LINK_TYPES.Page) + const [curHref, setHref] = useState(linkHref) + + return { + linkTypes, + curHref, + setHref: (value: string) => { + onChange(value) + setHref(value) + }, + error, + curType, + setCurType, + } +} + +export const LinkEditorContextProvider = ({ + children, + ...passthroughProps +}: PropsWithChildren) => { + const values = useLinkEditorContext(passthroughProps) + return ( + + {children} + + ) +} + +export const useLinkEditor = () => { + const context = useContext(LinkEditorContext) + if (!context) { + throw new Error( + `useLinkEditor must be used within a LinkEditorContextProvider component`, + ) + } + return context +} diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkEditorRadioGroup.tsx b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkEditorRadioGroup.tsx new file mode 100644 index 000000000..655edec4a --- /dev/null +++ b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkEditorRadioGroup.tsx @@ -0,0 +1,90 @@ +import type { UseRadioProps } from "@chakra-ui/react" +import type { PropsWithChildren } from "react" +import { + Box, + HStack, + Icon, + Text, + useRadio, + useRadioGroup, +} from "@chakra-ui/react" + +import type { LinkTypes } from "./constants" +import { LINK_TYPES } from "./constants" +import { useLinkEditor } from "./LinkEditorContext" + +const LinkTypeRadioCard = ({ + children, + ...rest +}: PropsWithChildren) => { + const { getInputProps, getRadioProps } = useRadio(rest) + + return ( + div": { + borderLeftRadius: "base", + }, + }} + _last={{ + "> div": { + borderRightRadius: "base", + }, + }} + > + + + + {children} + + + ) +} + +export const LinkEditorRadioGroup = () => { + const { linkTypes, setCurType } = useLinkEditor() + const { getRootProps, getRadioProps } = useRadioGroup({ + name: "link-type", + defaultValue: LINK_TYPES.Page, + // NOTE: This is a safe cast because we map over the `linkTypes` below + // so each time we are using the `linkType` + onChange: (value) => setCurType(value as LinkTypes), + }) + + return ( + + {Object.entries(linkTypes).map(([key, props]) => { + if (!props) return null + const { icon, label } = props + const radio = getRadioProps({ value: key }) + + return ( + + + + {label} + + + ) + })} + + ) +} diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkHrefEditor.tsx b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkHrefEditor.tsx index aff46efc2..75cff7d3b 100644 --- a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkHrefEditor.tsx +++ b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkHrefEditor.tsx @@ -1,84 +1,96 @@ import type { ReactNode } from "react" -import { useState } from "react" import { Box, FormControl, - HStack, - Icon, - Text, - useRadioGroup, + Input, + InputGroup, + InputLeftAddon, } from "@chakra-ui/react" import { FormLabel } from "@opengovsg/design-system-react" -import type { LinkTypeMapping, LinkTypes } from "./constants" -import { LinkTypeRadioCard } from "./LinkTypeRadioCard" -import { LinkTypeRadioContent } from "./LinkTypeRadioContent" -import { getLinkHrefType } from "./utils" +import { LINK_TYPES } from "./constants" +import { useLinkEditor } from "./LinkEditorContext" +import { LinkEditorRadioGroup } from "./LinkEditorRadioGroup" + +const HTTPS_PREFIX = "https://" +type HttpsLink = `https://${string}` + +const generateHttpsLink = (data: string): HttpsLink => { + if (data.startsWith(HTTPS_PREFIX)) { + return data as HttpsLink + } + + return `${HTTPS_PREFIX}${data}` +} interface LinkHrefEditorProps { - value: string - onChange: (href?: string) => void label: string description?: string isRequired?: boolean isInvalid?: boolean pageLinkElement: ReactNode fileLinkElement: ReactNode - linkTypes: LinkTypeMapping } export const LinkHrefEditor = ({ - value, - onChange, label, description, isRequired, isInvalid, pageLinkElement, fileLinkElement, - linkTypes, }: LinkHrefEditorProps) => { - const linkType = getLinkHrefType(value) - const [selectedLinkType, setSelectedLinkType] = useState(linkType) - - const handleLinkTypeChange = (value: LinkTypes) => { - setSelectedLinkType(value) - onChange() - } - - const { getRootProps, getRadioProps } = useRadioGroup({ - name: "link-type", - defaultValue: linkType, - onChange: handleLinkTypeChange, - }) + const { curHref, setHref, curType } = useLinkEditor() return ( {label} - - {Object.entries(linkTypes).map(([key, { icon, label }]) => { - const radio = getRadioProps({ value: key }) - - return ( - - - - {label} - - - ) - })} - + - + {curType === LINK_TYPES.Page && pageLinkElement} + {curType === LINK_TYPES.File && fileLinkElement} + {curType === LINK_TYPES.External && ( + + https:// + { + if (!e.target.value) { + setHref(e.target.value) + } + setHref(generateHttpsLink(e.target.value)) + }} + placeholder="www.isomer.gov.sg" + /> + + )} + {curType === LINK_TYPES.Email && ( + + mailto: + { + if (!e.target.value) { + setHref(e.target.value) + } + setHref(`mailto:${e.target.value}`) + }} + placeholder="test@example.com" + /> + + )} ) diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkTypeRadioCard.tsx b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkTypeRadioCard.tsx deleted file mode 100644 index 7a23f9c38..000000000 --- a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkTypeRadioCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { UseRadioProps } from "@chakra-ui/react" -import type { PropsWithChildren } from "react" -import { Box, useRadio } from "@chakra-ui/react" - -export const LinkTypeRadioCard = ({ - children, - ...rest -}: PropsWithChildren) => { - const { getInputProps, getRadioProps } = useRadio(rest) - - return ( - div": { - borderLeftRadius: "base", - }, - }} - _last={{ - "> div": { - borderRightRadius: "base", - }, - }} - > - - - - {children} - - - ) -} diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkTypeRadioContent.tsx b/apps/studio/src/features/editing-experience/components/LinkEditor/LinkTypeRadioContent.tsx deleted file mode 100644 index a9d0fd931..000000000 --- a/apps/studio/src/features/editing-experience/components/LinkEditor/LinkTypeRadioContent.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { ReactNode } from "react" -import { InputGroup, InputLeftAddon } from "@chakra-ui/react" -import { Input } from "@opengovsg/design-system-react" - -import { LINK_TYPES } from "./constants" - -const HTTPS_PREFIX = "https://" -type HttpsLink = `https://${string}` - -const generateHttpsLink = (data: string): HttpsLink => { - if (data.startsWith(HTTPS_PREFIX)) { - return data as HttpsLink - } - - return `https://${data}` -} - -interface LinkTypeRadioContentProps { - selectedLinkType: string - data: string - handleChange: (value: string) => void - pageLinkElement: ReactNode - fileLinkElement: ReactNode -} - -export const LinkTypeRadioContent = ({ - selectedLinkType, - data, - handleChange, - pageLinkElement, - fileLinkElement, -}: LinkTypeRadioContentProps): JSX.Element => { - return ( - <> - {selectedLinkType === LINK_TYPES.Page && pageLinkElement} - {selectedLinkType === LINK_TYPES.File && fileLinkElement} - {selectedLinkType === LINK_TYPES.External && ( - - https:// - { - if (!e.target.value) { - handleChange(e.target.value) - } - handleChange(generateHttpsLink(e.target.value)) - }} - placeholder="www.isomer.gov.sg" - /> - - )} - {selectedLinkType === LINK_TYPES.Email && ( - - mailto: - { - if (!e.target.value) { - handleChange(e.target.value) - } - handleChange(`mailto:${e.target.value}`) - }} - placeholder="test@example.com" - /> - - )} - - ) -} diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/constants.ts b/apps/studio/src/features/editing-experience/components/LinkEditor/constants.ts index d00c199e8..81333e11f 100644 --- a/apps/studio/src/features/editing-experience/components/LinkEditor/constants.ts +++ b/apps/studio/src/features/editing-experience/components/LinkEditor/constants.ts @@ -1,4 +1,4 @@ -import type { IconType } from "react-icons" +import { IconType } from "react-icons" import { BiEnvelopeOpen, BiFile, BiFileBlank, BiLink } from "react-icons/bi" export const LINK_TYPES = { diff --git a/apps/studio/src/features/editing-experience/components/LinkEditor/index.ts b/apps/studio/src/features/editing-experience/components/LinkEditor/index.ts index b5e895c18..67d889d67 100644 --- a/apps/studio/src/features/editing-experience/components/LinkEditor/index.ts +++ b/apps/studio/src/features/editing-experience/components/LinkEditor/index.ts @@ -1,3 +1 @@ export * from "./LinkHrefEditor" -export * from "./LinkTypeRadioCard" -export * from "./LinkTypeRadioContent" diff --git a/apps/studio/src/features/editing-experience/components/RootStateDrawer.tsx b/apps/studio/src/features/editing-experience/components/RootStateDrawer.tsx index f8d926a5e..8310107a9 100644 --- a/apps/studio/src/features/editing-experience/components/RootStateDrawer.tsx +++ b/apps/studio/src/features/editing-experience/components/RootStateDrawer.tsx @@ -27,7 +27,7 @@ const FIXED_BLOCK_CONTENT: Record = { }, content: { label: "Content page header", - description: "Summary, Button label, and Button URL", + description: "Summary, Button label, and Button destination", }, } diff --git a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsLinkControl.tsx b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsLinkControl.tsx index f07caecd1..bf0dad25c 100644 --- a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsLinkControl.tsx +++ b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsLinkControl.tsx @@ -34,6 +34,11 @@ import { getReferenceLink, getResourceIdFromReferenceLink } from "~/utils/link" import { trpc } from "~/utils/trpc" import { LinkHrefEditor } from "../../../LinkEditor" import { LINK_TYPES_MAPPING } from "../../../LinkEditor/constants" +import { + LinkEditorContextProvider, + useLinkEditor, +} from "../../../LinkEditor/LinkEditorContext" +import { getLinkHrefType } from "../../../LinkEditor/utils" export const jsonFormsLinkControlTester: RankedTester = rankWith( JSON_FORMS_RANKING.LinkControl, @@ -259,8 +264,42 @@ export function JsonFormsLinkControl({ path, description, required, + errors, }: ControlProps) { const dataString = data && typeof data === "string" ? data : "" + + return ( + + handleChange(path, value)} + > + + + + ) +} + +interface LinkEditorContentProps { + label: string + isRequired?: boolean + value: string + description?: string +} +const LinkEditorContent = ({ + value, + label, + isRequired, + description, +}: LinkEditorContentProps) => { + const { setHref } = useLinkEditor() // NOTE: We need to pass in `siteId` but this component is automatically used by JsonForms // so we are unable to pass props down const { siteId } = useQueryParse(siteSchema) @@ -268,38 +307,34 @@ export function JsonFormsLinkControl({ // the data passed to this component is '/' // which prevents this component from saving const dummyFile = - !!dataString && dataString !== "/" + !!value && value !== "/" && getLinkHrefType(value) === "file" ? new File( [], // NOTE: Technically guaranteed since our s3 filepath has a format of `//.../` - dataString.split("/").at(-1) ?? "Uploaded file", + value.split("/").at(-1) ?? "Uploaded file", ) : undefined return ( - - handleChange(path, value)} - label={label} - isRequired={required} - pageLinkElement={ - handleChange(path, value)} - /> - } - fileLinkElement={ - handleChange(path, value)} - value={dummyFile} - /> - } - /> - + + } + fileLinkElement={ + setHref(value ?? "")} + value={dummyFile} + /> + } + /> ) } diff --git a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsRefControl.tsx b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsRefControl.tsx index 6c9751880..af4a85187 100644 --- a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsRefControl.tsx +++ b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsRefControl.tsx @@ -43,7 +43,13 @@ const SuspendableLabel = ({ resourceId }: { resourceId: string }) => { resourceId, }) - return {`/${fullPermalink}`} + return ( + {`/${fullPermalink}`} + ) } export function JsonFormsRefControl({ @@ -51,7 +57,6 @@ export function JsonFormsRefControl({ handleChange, path, label, - errors, }: ControlProps) { const dataString = data && typeof data === "string" ? data : "" const { isOpen, onOpen, onClose } = useDisclosure() @@ -63,7 +68,7 @@ export function JsonFormsRefControl({ return ( <> - + {label} Choose a page or file to link this Collection item to - {" "} )} diff --git a/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx b/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx index 43e5a5b25..7d6bc249c 100644 --- a/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx +++ b/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx @@ -21,6 +21,8 @@ const COMMON_HANDLERS = [ sitesHandlers.getNavbar.default(), sitesHandlers.getLocalisedSitemap.default(), resourceHandlers.getRolesFor.default(), + resourceHandlers.getWithFullPermalink.default(), + resourceHandlers.getAncestryOf.collectionLink(), resourceHandlers.getChildrenOf.default(), resourceHandlers.getMetadataById.article(), pageHandlers.readPageAndBlob.article(), @@ -99,3 +101,23 @@ export const WithBanner: Story = { ], }, } + +export const AddTextBlock: Story = { + play: async (context) => { + const { canvasElement } = context + const canvas = within(canvasElement) + await AddBlock.play?.(context) + + await userEvent.click(canvas.getByRole("button", { name: /text/i })) + }, +} + +export const LinkModal: Story = { + play: async (context) => { + const { canvasElement } = context + const canvas = within(canvasElement) + await AddTextBlock.play?.(context) + + await userEvent.click(canvas.getByRole("button", { name: /link/i })) + }, +} diff --git a/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx b/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx index b75f49fbc..73f1b8550 100644 --- a/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx +++ b/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react" +import { userEvent, waitFor, within } from "@storybook/test" import { ResourceState } from "~prisma/generated/generatedEnums" import { collectionHandlers } from "tests/msw/handlers/collection" import { meHandlers } from "tests/msw/handlers/me" @@ -21,6 +22,8 @@ const COMMON_HANDLERS = [ sitesHandlers.getNavbar.default(), sitesHandlers.getLocalisedSitemap.default(), resourceHandlers.getRolesFor.default(), + resourceHandlers.getWithFullPermalink.default(), + resourceHandlers.getAncestryOf.collectionLink(), resourceHandlers.getChildrenOf.default(), resourceHandlers.getMetadataById.article(), resourceHandlers.getParentOf.collection(), @@ -80,3 +83,17 @@ export const WithBanner: Story = { ], }, } + +export const WithModal: Story = { + play: async (context) => { + const { canvasElement } = context + const screen = within(canvasElement) + + await waitFor( + async () => + await userEvent.click( + screen.getByRole("button", { name: /Link something.../i }), + ), + ) + }, +} diff --git a/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx b/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx index 377480aed..3167fc7c2 100644 --- a/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx +++ b/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx @@ -21,6 +21,8 @@ const COMMON_HANDLERS = [ sitesHandlers.getNavbar.default(), sitesHandlers.getLocalisedSitemap.default(), resourceHandlers.getChildrenOf.default(), + resourceHandlers.getWithFullPermalink.default(), + resourceHandlers.getAncestryOf.collectionLink(), resourceHandlers.getMetadataById.content(), resourceHandlers.getRolesFor.default(), pageHandlers.readPageAndBlob.content(), @@ -111,3 +113,13 @@ export const AddTextBlock: Story = { ) }, } + +export const LinkModal: Story = { + play: async (context) => { + const { canvasElement } = context + const canvas = within(canvasElement) + await AddTextBlock.play?.(context) + + await userEvent.click(canvas.getByRole("button", { name: /link/i })) + }, +} diff --git a/apps/studio/tests/msw/handlers/resource.ts b/apps/studio/tests/msw/handlers/resource.ts index 13b8f11ba..72db929c2 100644 --- a/apps/studio/tests/msw/handlers/resource.ts +++ b/apps/studio/tests/msw/handlers/resource.ts @@ -43,6 +43,31 @@ export const resourceHandlers = { }) }, }, + getAncestryOf: { + collectionLink: () => { + return trpcMsw.resource.getAncestryOf.query(() => { + return [ + { + parentId: null, + id: "1", + title: "Homepage", + permalink: "/", + }, + ] + }) + }, + }, + getWithFullPermalink: { + default: () => { + return trpcMsw.resource.getWithFullPermalink.query(() => { + return { + id: "1", + title: "Homepage", + fullPermalink: "folder/page", + } + }) + }, + }, getMetadataById: { homepage: () => trpcMsw.resource.getMetadataById.query(() => { diff --git a/packages/components/src/interfaces/internal/ContentPageHeader.ts b/packages/components/src/interfaces/internal/ContentPageHeader.ts index 3c1fed37d..fb8c9dac5 100644 --- a/packages/components/src/interfaces/internal/ContentPageHeader.ts +++ b/packages/components/src/interfaces/internal/ContentPageHeader.ts @@ -16,13 +16,14 @@ export const ContentPageHeaderSchema = Type.Object( buttonLabel: Type.Optional( Type.String({ title: "Button label", - description: "The label for the button", + description: + "A descriptive text. Avoid generic text like “Here”, “Click here”, or “Learn more”", }), ), buttonUrl: Type.Optional( Type.String({ - title: "Button URL", - description: "The URL the button should link to", + title: "Button destination", + description: "When this is clicked, open:", format: "link", pattern: LINK_HREF_PATTERN, }),