diff --git a/apps/studio/public/assets/css/preview-tw.css b/apps/studio/public/assets/css/preview-tw.css index 6a8a290cc..b4d63c294 100644 --- a/apps/studio/public/assets/css/preview-tw.css +++ b/apps/studio/public/assets/css/preview-tw.css @@ -1766,6 +1766,10 @@ video { flex-shrink: 0; } +.flex-grow { + flex-grow: 1; +} + .grow { flex-grow: 1; } 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/components/PageEditor/MenuBar/AccordionMenuBar.tsx b/apps/studio/src/components/PageEditor/MenuBar/AccordionMenuBar.tsx index bc31a0bd4..d6cf58ad2 100644 --- a/apps/studio/src/components/PageEditor/MenuBar/AccordionMenuBar.tsx +++ b/apps/studio/src/components/PageEditor/MenuBar/AccordionMenuBar.tsx @@ -72,22 +72,6 @@ export const AccordionMenuBar = ({ editor }: { editor: Editor }) => { action: () => editor.chain().focus().toggleStrike().run(), isActive: () => editor.isActive("strike"), }, - { - type: "item", - icon: MdSuperscript, - title: "Superscript", - action: () => - editor.chain().focus().unsetSubscript().toggleSuperscript().run(), - isActive: () => editor.isActive("superscript"), - }, - { - type: "item", - icon: MdSubscript, - title: "Subscript", - action: () => - editor.chain().focus().unsetSuperscript().toggleSubscript().run(), - isActive: () => editor.isActive("subscript"), - }, { type: "divider", }, @@ -204,6 +188,28 @@ export const AccordionMenuBar = ({ editor }: { editor: Editor }) => { }, ], }, + // Lesser-used commands are kept inside the overflow items list + { + type: "overflow-list", + items: [ + { + type: "item", + icon: MdSuperscript, + title: "Superscript", + action: () => + editor.chain().focus().unsetSubscript().toggleSuperscript().run(), + isActive: () => editor.isActive("superscript"), + }, + { + type: "item", + icon: MdSubscript, + title: "Subscript", + action: () => + editor.chain().focus().unsetSuperscript().toggleSubscript().run(), + isActive: () => editor.isActive("subscript"), + }, + ], + }, ], [editor, onLinkModalOpen, onTableSettingsModalOpen], ) diff --git a/apps/studio/src/components/PageEditor/MenuBar/CalloutMenuBar.tsx b/apps/studio/src/components/PageEditor/MenuBar/CalloutMenuBar.tsx index a9450a00e..47c61d1bc 100644 --- a/apps/studio/src/components/PageEditor/MenuBar/CalloutMenuBar.tsx +++ b/apps/studio/src/components/PageEditor/MenuBar/CalloutMenuBar.tsx @@ -53,22 +53,6 @@ export const CalloutMenuBar = ({ editor }: { editor: Editor }) => { action: () => editor.chain().focus().toggleStrike().run(), isActive: () => editor.isActive("strike"), }, - { - type: "item", - icon: MdSuperscript, - title: "Superscript", - action: () => - editor.chain().focus().unsetSubscript().toggleSuperscript().run(), - isActive: () => editor.isActive("superscript"), - }, - { - type: "item", - icon: MdSubscript, - title: "Subscript", - action: () => - editor.chain().focus().unsetSuperscript().toggleSubscript().run(), - isActive: () => editor.isActive("subscript"), - }, { type: "divider", }, @@ -104,6 +88,28 @@ export const CalloutMenuBar = ({ editor }: { editor: Editor }) => { action: onLinkModalOpen, isActive: () => editor.isActive("link"), }, + // Lesser-used commands are kept inside the overflow items list + { + type: "overflow-list", + items: [ + { + type: "item", + icon: MdSuperscript, + title: "Superscript", + action: () => + editor.chain().focus().unsetSubscript().toggleSuperscript().run(), + isActive: () => editor.isActive("superscript"), + }, + { + type: "item", + icon: MdSubscript, + title: "Subscript", + action: () => + editor.chain().focus().unsetSuperscript().toggleSubscript().run(), + isActive: () => editor.isActive("subscript"), + }, + ], + }, ], [editor, onLinkModalOpen], ) diff --git a/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/Factory.tsx b/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/Factory.tsx index a5ec3e4bb..f8a686307 100644 --- a/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/Factory.tsx +++ b/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/Factory.tsx @@ -3,6 +3,7 @@ import { MenubarDetailedList } from "./DetailedList" import { MenubarDivider } from "./Divider" import { MenubarHorizontalList } from "./HorizontalList" import { MenubarItem } from "./Item" +import { MenubarOverflowList } from "./OverflowList" import { MenubarVerticalList } from "./VerticalList" export const MenubarItemFactory = ( @@ -19,5 +20,10 @@ export const MenubarItemFactory = ( return case "item": return + case "overflow-list": + return + default: + const _: never = item + return <> } } diff --git a/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/OverflowList.tsx b/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/OverflowList.tsx new file mode 100644 index 000000000..329ba1407 --- /dev/null +++ b/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/OverflowList.tsx @@ -0,0 +1,68 @@ +import { + HStack, + Icon, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from "@chakra-ui/react" +import { IconButton } from "@opengovsg/design-system-react" +import { BiDotsHorizontalRounded } from "react-icons/bi" + +import type { MenubarNestedItem } from "./types" +import { MenuItem } from "../../MenuItem" + +export interface MenubarOverflowListProps { + type: "overflow-list" + items: MenubarNestedItem[] +} + +export const MenubarOverflowList = ({ + items, +}: MenubarOverflowListProps): JSX.Element => { + return ( + + {({ isOpen }) => ( + <> + + + + + + + + + {items.map((subItem, index) => ( + + ))} + + + + + )} + + ) +} diff --git a/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/types.ts b/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/types.ts index bf735b9be..5ad2abff1 100644 --- a/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/types.ts +++ b/apps/studio/src/components/PageEditor/MenuBar/MenubarItem/types.ts @@ -4,6 +4,7 @@ import type { MenubarDetailedListProps } from "./DetailedList" import type { MenubarDividerProps } from "./Divider" import type { MenubarHorizontalListProps } from "./HorizontalList" import type { MenubarItemProps } from "./Item" +import type { MenubarOverflowListProps } from "./OverflowList" import type { MenubarVerticalListProps } from "./VerticalList" export interface MenubarNestedItem { @@ -24,3 +25,4 @@ export type PossibleMenubarItemProps = | MenubarHorizontalListProps | MenubarDetailedListProps | MenubarItemProps + | MenubarOverflowListProps diff --git a/apps/studio/src/components/PageEditor/MenuBar/ProseMenuBar.tsx b/apps/studio/src/components/PageEditor/MenuBar/ProseMenuBar.tsx index ad8e8c849..1ead54492 100644 --- a/apps/studio/src/components/PageEditor/MenuBar/ProseMenuBar.tsx +++ b/apps/studio/src/components/PageEditor/MenuBar/ProseMenuBar.tsx @@ -107,22 +107,6 @@ export const ProseMenuBar = ({ editor }: { editor: Editor }) => { action: () => editor.chain().focus().toggleStrike().run(), isActive: () => editor.isActive("strike"), }, - { - type: "item", - icon: MdSuperscript, - title: "Superscript", - action: () => - editor.chain().focus().unsetSubscript().toggleSuperscript().run(), - isActive: () => editor.isActive("superscript"), - }, - { - type: "item", - icon: MdSubscript, - title: "Subscript", - action: () => - editor.chain().focus().unsetSuperscript().toggleSubscript().run(), - isActive: () => editor.isActive("subscript"), - }, { type: "horizontal-list", label: "Lists", @@ -152,6 +136,28 @@ export const ProseMenuBar = ({ editor }: { editor: Editor }) => { action: onLinkModalOpen, isActive: () => editor.isActive("link"), }, + // Lesser-used commands are kept inside the overflow items list + { + type: "overflow-list", + items: [ + { + type: "item", + icon: MdSuperscript, + title: "Superscript", + action: () => + editor.chain().focus().unsetSubscript().toggleSuperscript().run(), + isActive: () => editor.isActive("superscript"), + }, + { + type: "item", + icon: MdSubscript, + title: "Subscript", + action: () => + editor.chain().focus().unsetSuperscript().toggleSubscript().run(), + isActive: () => editor.isActive("subscript"), + }, + ], + }, ], [editor, onLinkModalOpen], ) diff --git a/apps/studio/src/components/PageEditor/MenuBar/TextMenuBar.tsx b/apps/studio/src/components/PageEditor/MenuBar/TextMenuBar.tsx index e5328fd61..3b932d9a4 100644 --- a/apps/studio/src/components/PageEditor/MenuBar/TextMenuBar.tsx +++ b/apps/studio/src/components/PageEditor/MenuBar/TextMenuBar.tsx @@ -127,22 +127,6 @@ export const TextMenuBar = ({ editor }: { editor: Editor }) => { action: () => editor.chain().focus().toggleStrike().run(), isActive: () => editor.isActive("strike"), }, - { - type: "item", - icon: MdSuperscript, - title: "Superscript", - action: () => - editor.chain().focus().unsetSubscript().toggleSuperscript().run(), - isActive: () => editor.isActive("superscript"), - }, - { - type: "item", - icon: MdSubscript, - title: "Subscript", - action: () => - editor.chain().focus().unsetSuperscript().toggleSubscript().run(), - isActive: () => editor.isActive("subscript"), - }, { type: "horizontal-list", label: "Lists", @@ -254,6 +238,28 @@ export const TextMenuBar = ({ editor }: { editor: Editor }) => { }, ], }, + // Lesser-used commands are kept inside the overflow items list + { + type: "overflow-list", + items: [ + { + type: "item", + icon: MdSuperscript, + title: "Superscript", + action: () => + editor.chain().focus().unsetSubscript().toggleSuperscript().run(), + isActive: () => editor.isActive("superscript"), + }, + { + type: "item", + icon: MdSubscript, + title: "Subscript", + action: () => + editor.chain().focus().unsetSuperscript().toggleSubscript().run(), + isActive: () => editor.isActive("subscript"), + }, + ], + }, ], [editor, onLinkModalOpen, onTableSettingsModalOpen], ) diff --git a/apps/studio/src/features/editing-experience/components/Block/BaseBlock.tsx b/apps/studio/src/features/editing-experience/components/Block/BaseBlock.tsx index 05bfdd8d4..6b983f084 100644 --- a/apps/studio/src/features/editing-experience/components/Block/BaseBlock.tsx +++ b/apps/studio/src/features/editing-experience/components/Block/BaseBlock.tsx @@ -40,7 +40,7 @@ export const BaseBlock = ({ shadow: "0px 1px 6px 0px #1361F026", }} bg="white" - py="0.5rem" + py="0.75rem" px="0.75rem" flexDirection="row" align="center" @@ -54,13 +54,27 @@ export const BaseBlock = ({ p="0.25rem" bg="interaction.main-subtle.default" borderRadius="4px" + mr="0.25rem" > )} - {label} - {description && {description}} + + {label} + + {description && ( + + {description} + + )} ) diff --git a/apps/studio/src/features/editing-experience/components/Block/DraggableBlock.tsx b/apps/studio/src/features/editing-experience/components/Block/DraggableBlock.tsx index b9ea6739e..56ab75109 100644 --- a/apps/studio/src/features/editing-experience/components/Block/DraggableBlock.tsx +++ b/apps/studio/src/features/editing-experience/components/Block/DraggableBlock.tsx @@ -2,7 +2,10 @@ import type { IsomerSchema } from "@opengovsg/isomer-components" import { useMemo } from "react" import { VStack } from "@chakra-ui/react" import { Draggable } from "@hello-pangea/dnd" -import { getComponentSchema } from "@opengovsg/isomer-components" +import { + getComponentSchema, + renderComponentPreviewText, +} from "@opengovsg/isomer-components" import { PROSE_COMPONENT_NAME } from "~/constants/formBuilder" import { TYPE_TO_ICON } from "../../constants" @@ -23,7 +26,7 @@ export const DraggableBlock = ({ }: DraggableBlockProps): JSX.Element => { const icon = TYPE_TO_ICON[block.type] - const label = useMemo(() => { + const blockComponentName = useMemo(() => { // NOTE: Because we use `Type.Ref` for prose, // this gets a `$Ref` only and not the concrete values return block.type === "prose" @@ -31,6 +34,10 @@ export const DraggableBlock = ({ : (getComponentSchema(block.type).title ?? "Unknown") }, [block.type]) + const previewText: string = renderComponentPreviewText({ + component: block, + }) + return ( } - label={label} + label={ + previewText === "" + ? `Empty ${blockComponentName.toLowerCase()}` + : previewText + } + description={blockComponentName} icon={icon} /> 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 345302978..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(), @@ -99,3 +101,25 @@ 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: /Add a block of 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/tests/msw/handlers/page.ts b/apps/studio/tests/msw/handlers/page.ts index 3eab696a0..66ff47ca4 100644 --- a/apps/studio/tests/msw/handlers/page.ts +++ b/apps/studio/tests/msw/handlers/page.ts @@ -187,13 +187,6 @@ export const pageHandlers = { secondaryButtonUrl: "/", secondaryButtonLabel: "Sub CTA", }, - { - type: "infopic", - title: "This is an infopic", - imageSrc: "https://placehold.co/600x400", - description: - "This is the description for the infopic component", - }, { type: "keystatistics", title: "Irrationality in numbers", @@ -220,6 +213,22 @@ export const pageHandlers = { description: "This is the description that goes into the Infobar section", }, + { + type: "infopic", + title: "This is an infopic", + imageSrc: "https://placehold.co/600x400", + }, + { + type: "infocards", + title: "This is an infocards block", + variant: "cardsWithoutImages", + cards: [], + }, + { + type: "infocols", + title: "This is an infocols block", + infoBoxes: [], + }, ], version: "0.1.0", }, @@ -323,6 +332,87 @@ export const pageHandlers = { }, ], }, + { + type: "prose", + content: [ + { + type: "paragraph", + content: [], + }, + ], + }, + { + type: "image", + src: "https://placehold.co/600x400", + alt: "This is an image", + }, + { + type: "callout", + content: { + type: "prose", + content: [ + { + type: "paragraph", + content: [ + { text: "This is a callout block", type: "text" }, + ], + }, + ], + }, + }, + { + type: "callout", + content: { + type: "prose", + content: [ + { + type: "paragraph", + content: [], + }, + ], + }, + }, + { + type: "contentpic", + content: { + type: "prose", + content: [ + { + type: "paragraph", + content: [ + { text: "This is a contentpic block", type: "text" }, + ], + }, + ], + }, + }, + { + type: "contentpic", + content: { + type: "prose", + content: [], + }, + imageSrc: "https://placehold.co/600x400", + }, + { + type: "infocards", + title: "This is an infocards block", + variant: "cardsWithoutImages", + cards: [], + }, + { + type: "accordion", + summary: "This is an accordion block", + details: { + type: "prose", + content: [], + }, + }, + { + type: "infocols", + title: "This is an infocols block", + infoBoxes: [], + }, ], version: "0.1.0", }, @@ -416,7 +506,57 @@ export const pageHandlers = { articlePageHeader: { summary: "" }, }, layout: "article", - content: [], + content: [ + { + type: "prose", + content: [ + { + type: "paragraph", + content: [{ text: "This is a prose block", type: "text" }], + }, + ], + }, + { + type: "prose", + content: [ + { + type: "paragraph", + content: [], + }, + ], + }, + { + type: "image", + src: "https://placehold.co/600x400", + alt: "This is an image", + }, + { + type: "callout", + content: { + type: "prose", + content: [ + { + type: "paragraph", + content: [ + { text: "This is a callout block", type: "text" }, + ], + }, + ], + }, + }, + { + type: "callout", + content: { + type: "prose", + content: [ + { + type: "paragraph", + content: [], + }, + ], + }, + }, + ], version: "0.1.0", }, type: "Page", 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/engine/index.ts b/packages/components/src/engine/index.ts index c6912c673..c3daf1462 100644 --- a/packages/components/src/engine/index.ts +++ b/packages/components/src/engine/index.ts @@ -1,3 +1,3 @@ -export { RenderEngine } from "./render" +export { RenderEngine, renderComponentPreviewText } from "./render" export { getMetadata, getRobotsTxt, getSitemapXml } from "./metadata" export * from "~/types" diff --git a/packages/components/src/engine/render.tsx b/packages/components/src/engine/render.tsx index d1ca7d990..d1f573e78 100644 --- a/packages/components/src/engine/render.tsx +++ b/packages/components/src/engine/render.tsx @@ -1,6 +1,8 @@ import type { IsomerPageSchemaType } from "~/engine" import { renderLayout as renderNextLayout } from "~/templates/next" +export { renderComponentPreviewText } from "~/templates/next" + export const RenderEngine = (props: IsomerPageSchemaType) => { if (props.site.theme === "isomer-next") { return renderNextLayout(props) @@ -8,5 +10,3 @@ export const RenderEngine = (props: IsomerPageSchemaType) => { return null } - -export default RenderEngine diff --git a/packages/components/src/interfaces/internal/CollectionCard.ts b/packages/components/src/interfaces/internal/CollectionCard.ts index 62fb69ebe..023683b17 100644 --- a/packages/components/src/interfaces/internal/CollectionCard.ts +++ b/packages/components/src/interfaces/internal/CollectionCard.ts @@ -33,7 +33,20 @@ export interface LinkCardProps extends BaseCardProps { variant: "link" } -export type CollectionCardProps = - | ArticleCardProps - | FileCardProps - | LinkCardProps +export type AllCardProps = ArticleCardProps | FileCardProps | LinkCardProps + +// NOTE: This is client-side rendering and we want as much pre-processing +// on the server as possible to improve performance + reduce file and bandwidth size +// Thus, only the necessary props are passed to this component. +export type CollectionCardProps = Pick< + AllCardProps, + "lastUpdated" | "category" | "title" | "description" | "image" +> & { + referenceLinkHref: string | undefined + imageSrc: string | undefined + itemTitle: string +} + +// NOTE: This is to ensure no additional props are being passed to this component +export type ProcessedCollectionCardProps = CollectionCardProps & + Record 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, }), diff --git a/packages/components/src/interfaces/internal/index.ts b/packages/components/src/interfaces/internal/index.ts index ebad620e7..cbe904e86 100644 --- a/packages/components/src/interfaces/internal/index.ts +++ b/packages/components/src/interfaces/internal/index.ts @@ -3,8 +3,12 @@ export { type ArticlePageHeaderProps, } from "./ArticlePageHeader" export type { BreadcrumbProps } from "./Breadcrumb" +export type { + AllCardProps, + CollectionCardProps, + ProcessedCollectionCardProps, +} from "./CollectionCard" export type { ChildrenPagesProps } from "./ChildrenPages" -export type { CollectionCardProps } from "./CollectionCard" export type { ContentProps } from "./Content" export { ContentPageHeaderSchema, diff --git a/packages/components/src/interfaces/native/index.ts b/packages/components/src/interfaces/native/index.ts index eb503d2e3..70d7c24a3 100644 --- a/packages/components/src/interfaces/native/index.ts +++ b/packages/components/src/interfaces/native/index.ts @@ -6,3 +6,4 @@ export { ParagraphSchema, type ParagraphProps } from "./Paragraph" export { ProseSchema, type ProseContent, type ProseProps } from "./Prose" export { TableSchema, type TableProps } from "./Table" export { UnorderedListSchema, type UnorderedListProps } from "./UnorderedList" +export { TextSchema, type TextProps } from "./Text" diff --git a/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.stories.tsx b/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.stories.tsx index 33ed7a9d3..53d52162d 100644 --- a/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.stories.tsx +++ b/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.stories.tsx @@ -5,7 +5,7 @@ import { withChromaticModes } from "@isomer/storybook-config" import type { CollectionCardProps } from "~/interfaces" import { CollectionCard } from "./CollectionCard" -const meta: Meta = { +const meta: Meta = { title: "Next/Internal Components/CollectionCard", component: CollectionCard, argTypes: {}, @@ -16,56 +16,20 @@ const meta: Meta = { }, chromatic: withChromaticModes(["desktop", "mobile"]), }, - args: { - site: { - siteName: "Isomer Next", - siteMap: { - id: "1", - title: "Home", - permalink: "/", - lastModified: "", - layout: "homepage", - summary: "", - children: [], - }, - theme: "isomer-next", - isGovernment: true, - logoUrl: "https://www.isomer.gov.sg/images/isomer-logo.svg", - lastUpdated: "2021-10-01", - assetsBaseUrl: "https://cms.isomer.gov.sg", - navBarItems: [], - footerItems: { - privacyStatementLink: "https://www.isomer.gov.sg/privacy", - termsOfUseLink: "https://www.isomer.gov.sg/terms", - siteNavItems: [], - }, - search: { - type: "localSearch", - searchUrl: "/search", - }, - }, - }, } export default meta type Story = StoryObj const generateArgs = ({ - variant, shouldShowDate = true, isLastUpdatedUndefined = false, withoutImage = false, - fileDetails, title = "A journal on microscopic plastic and their correlation to the number of staycations enjoyed per millennials between the ages of 30-42, substantiated by research from IDK university", description = "We've looked at how people's spending correlates with how much microscopic plastic they consumed over the year. We've looked at how people's spending correlates with how much microscopic plastic they consumed over the year.", }: { - variant: string shouldShowDate?: boolean isLastUpdatedUndefined?: boolean withoutImage?: boolean - fileDetails?: { - type: string - size: string - } title?: string description?: string }): Partial & { shouldShowDate?: boolean } => { @@ -73,7 +37,6 @@ const generateArgs = ({ lastUpdated: isLastUpdatedUndefined ? undefined : "December 2, 2023", category: "Research", title, - url: "/", description, image: withoutImage ? undefined @@ -81,50 +44,34 @@ const generateArgs = ({ src: "https://placehold.co/500x500", alt: "placeholder", }, - variant: variant as "link" | "article" | "file" | undefined, - fileDetails, + referenceLinkHref: "/", + imageSrc: "https://placehold.co/500x500", + itemTitle: title, shouldShowDate, } } export const Default: Story = { - args: generateArgs({ variant: "article" }), + args: generateArgs({}), } export const UndefinedDate: Story = { - args: generateArgs({ variant: "article", isLastUpdatedUndefined: true }), + args: generateArgs({ isLastUpdatedUndefined: true }), } export const HideDate: Story = { args: generateArgs({ - variant: "article", shouldShowDate: false, isLastUpdatedUndefined: true, }), } -export const ArticleWithoutImage: Story = { - args: generateArgs({ variant: "article", withoutImage: true }), -} - -export const File: Story = { - args: generateArgs({ - variant: "file", - fileDetails: { type: "pdf", size: "2.3 MB" }, - }), -} - -export const FileWithoutImage: Story = { - args: generateArgs({ - variant: "file", - withoutImage: true, - fileDetails: { type: "pdf", size: "2.3 MB" }, - }), +export const CardWithoutImage: Story = { + args: generateArgs({ withoutImage: true }), } export const ShortDescription: Story = { args: generateArgs({ - variant: "article", title: "Short title", description: "Short description", }), diff --git a/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.tsx b/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.tsx index 785eba53c..daf7c5625 100644 --- a/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.tsx +++ b/packages/components/src/templates/next/components/internal/CollectionCard/CollectionCard.tsx @@ -2,45 +2,34 @@ import { Text } from "react-aria-components" -import type { CollectionCardProps as BaseCollectionCardProps } from "~/interfaces" +import type { CollectionCardProps } from "~/interfaces" +import type { CollectionPageSchemaType } from "~/types" import { tv } from "~/lib/tv" -import { - focusVisibleHighlight, - getFormattedDate, - getReferenceLinkHref, - isExternalUrl, -} from "~/utils" +import { focusVisibleHighlight, getFormattedDate } from "~/utils" import { ImageClient } from "../../complex/Image" import { Link } from "../Link" -type CollectionCardProps = BaseCollectionCardProps & { - shouldShowDate?: boolean -} - const collectionCardLinkStyle = tv({ extend: focusVisibleHighlight, base: "prose-title-md-semibold line-clamp-3 w-fit underline-offset-4 hover:underline", }) export const CollectionCard = ({ - shouldShowDate = true, LinkComponent, - url, lastUpdated, - title, description, category, image, - site, - ...props -}: CollectionCardProps): JSX.Element => { - const file = props.variant === "file" ? props.fileDetails : null - const itemTitle = `${title}${file ? ` [${file.type.toUpperCase()}, ${file.size.toUpperCase()}]` : ""}` - const imgSrc = - isExternalUrl(image?.src) || site.assetsBaseUrl === undefined - ? image?.src - : `${site.assetsBaseUrl}${image?.src}` - + referenceLinkHref, + imageSrc, + itemTitle, + siteAssetsBaseUrl, + shouldShowDate = true, +}: CollectionCardProps & { + shouldShowDate?: boolean + siteAssetsBaseUrl: string | undefined + LinkComponent: CollectionPageSchemaType["LinkComponent"] +}): JSX.Element => { return (
{shouldShowDate && ( @@ -52,7 +41,7 @@ export const CollectionCard = ({

{itemTitle} @@ -71,11 +60,11 @@ export const CollectionCard = ({ {image && (
)} diff --git a/packages/components/src/templates/next/layouts/Collection/Collection.stories.tsx b/packages/components/src/templates/next/layouts/Collection/Collection.stories.tsx index 5332f23c2..291407d9c 100644 --- a/packages/components/src/templates/next/layouts/Collection/Collection.stories.tsx +++ b/packages/components/src/templates/next/layouts/Collection/Collection.stories.tsx @@ -371,3 +371,17 @@ export const AllResultsNoDate: Story = { await expect(lastWordOccurences.length).toBe(10) }, } + +export const FileCard: Story = { + args: generateArgs({ + collectionItems: [COLLECTION_ITEMS[1]] as IsomerSitemap[], + }), +} + +export const FileCardNoImage: Story = { + args: generateArgs({ + collectionItems: [ + { ...COLLECTION_ITEMS[1], image: undefined } as IsomerSitemap, + ], + }), +} diff --git a/packages/components/src/templates/next/layouts/Collection/Collection.tsx b/packages/components/src/templates/next/layouts/Collection/Collection.tsx index 243b0b2a0..8e42c9336 100644 --- a/packages/components/src/templates/next/layouts/Collection/Collection.tsx +++ b/packages/components/src/templates/next/layouts/Collection/Collection.tsx @@ -1,9 +1,13 @@ +import type { Exact } from "type-fest" + import type { CollectionPageSchemaType, IsomerSiteProps } from "~/engine" -import type { CollectionCardProps } from "~/interfaces" +import type { AllCardProps, ProcessedCollectionCardProps } from "~/interfaces" import { getBreadcrumbFromSiteMap, getParsedDate, + getReferenceLinkHref, getSitemapAsArray, + isExternalUrl, } from "~/utils" import { Skeleton } from "../Skeleton" import CollectionClient from "./CollectionClient" @@ -12,7 +16,7 @@ import { getAvailableFilters, shouldShowDate } from "./utils" const getCollectionItems = ( site: IsomerSiteProps, permalink: string, -): CollectionCardProps[] => { +): AllCardProps[] => { let currSitemap = site.siteMap const permalinkParts = permalink.split("/") @@ -84,7 +88,7 @@ const getCollectionItems = ( variant: "article", url: item.permalink, } - }) satisfies CollectionCardProps[] + }) satisfies AllCardProps[] return transformedItems.sort((a, b) => { // Sort by last updated date, tiebreaker by title @@ -102,7 +106,42 @@ const getCollectionItems = ( } return a.rawDate < b.rawDate ? 1 : -1 - }) as CollectionCardProps[] + }) as AllCardProps[] +} + +const processedCollectionItems = ( + items: AllCardProps[], +): ProcessedCollectionCardProps[] => { + return items.map((item) => { + const { + site, + variant, + lastUpdated, + category, + title, + description, + image, + url, + } = item + const file = variant === "file" ? item.fileDetails : null + return { + lastUpdated, + category, + title, + description, + image, + referenceLinkHref: getReferenceLinkHref( + url, + site.siteMap, + site.assetsBaseUrl, + ), + imageSrc: + isExternalUrl(item.image?.src) || site.assetsBaseUrl === undefined + ? item.image?.src + : `${site.assetsBaseUrl}${item.image?.src}`, + itemTitle: `${item.title}${file ? ` [${file.type.toUpperCase()}, ${file.size.toUpperCase()}]` : ""}`, + } as Exact + }) } const CollectionLayout = ({ @@ -115,6 +154,7 @@ const CollectionLayout = ({ const { permalink } = page const items = getCollectionItems(site, permalink) + const processedItems = processedCollectionItems(items) const breadcrumb = getBreadcrumbFromSiteMap( site.siteMap, page.permalink.split("/").slice(1), @@ -131,11 +171,11 @@ const CollectionLayout = ({ ) diff --git a/packages/components/src/templates/next/layouts/Collection/CollectionClient.tsx b/packages/components/src/templates/next/layouts/Collection/CollectionClient.tsx index ff7631d11..ce3091192 100644 --- a/packages/components/src/templates/next/layouts/Collection/CollectionClient.tsx +++ b/packages/components/src/templates/next/layouts/Collection/CollectionClient.tsx @@ -4,7 +4,10 @@ import { useRef } from "react" import type { Filter as FilterType } from "../../types/Filter" import type { CollectionPageSchemaType } from "~/engine" -import type { BreadcrumbProps, CollectionCardProps } from "~/interfaces" +import type { + BreadcrumbProps, + ProcessedCollectionCardProps, +} from "~/interfaces" import { tv } from "~/lib/tv" import { BackToTopLink, @@ -18,12 +21,12 @@ import { ITEMS_PER_PAGE, useCollection } from "./useCollection" interface CollectionClientProps { page: CollectionPageSchemaType["page"] - items: CollectionCardProps[] + items: ProcessedCollectionCardProps[] filters: FilterType[] shouldShowDate: boolean + siteAssetsBaseUrl: string | undefined breadcrumb: BreadcrumbProps LinkComponent: CollectionPageSchemaType["LinkComponent"] - site: CollectionPageSchemaType["site"] } const createCollectionLayoutStyles = tv({ @@ -50,9 +53,9 @@ const CollectionClient = ({ items, filters, shouldShowDate, + siteAssetsBaseUrl, breadcrumb, LinkComponent, - site, }: CollectionClientProps) => { const { paginatedItems, @@ -124,8 +127,8 @@ const CollectionClient = ({ searchValue={searchValue} totalCount={totalCount} shouldShowDate={shouldShowDate} + siteAssetsBaseUrl={siteAssetsBaseUrl} LinkComponent={LinkComponent} - site={site} />

{paginatedItems.length > 0 && ( diff --git a/packages/components/src/templates/next/layouts/Collection/CollectionResults.tsx b/packages/components/src/templates/next/layouts/Collection/CollectionResults.tsx index 69efdb338..202ebc5bf 100644 --- a/packages/components/src/templates/next/layouts/Collection/CollectionResults.tsx +++ b/packages/components/src/templates/next/layouts/Collection/CollectionResults.tsx @@ -13,8 +13,8 @@ interface CollectionResultProps | "totalCount" > { shouldShowDate?: boolean + siteAssetsBaseUrl: string | undefined LinkComponent: CollectionPageSchemaType["LinkComponent"] - site: CollectionPageSchemaType["site"] } export const CollectionResults = ({ @@ -25,8 +25,8 @@ export const CollectionResults = ({ handleClearFilter, totalCount, shouldShowDate = true, + siteAssetsBaseUrl, LinkComponent, - site, }: CollectionResultProps) => { if (totalCount === 0) { return ( @@ -61,8 +61,8 @@ export const CollectionResults = ({ key={Math.random()} {...item} shouldShowDate={shouldShowDate} + siteAssetsBaseUrl={siteAssetsBaseUrl} LinkComponent={LinkComponent} - site={site} /> ))} {paginatedItems.length === 0 && ( diff --git a/packages/components/src/templates/next/layouts/Collection/useCollection.ts b/packages/components/src/templates/next/layouts/Collection/useCollection.ts index f5526e316..e6beb583a 100644 --- a/packages/components/src/templates/next/layouts/Collection/useCollection.ts +++ b/packages/components/src/templates/next/layouts/Collection/useCollection.ts @@ -7,7 +7,7 @@ import { } from "react" import type { AppliedFilter } from "../../types/Filter" -import type { CollectionCardProps } from "~/interfaces" +import type { ProcessedCollectionCardProps } from "~/interfaces" import { getFilteredItems, getPaginatedItems, @@ -16,10 +16,11 @@ import { export const ITEMS_PER_PAGE = 10 -interface UseCollectionProps { - items: CollectionCardProps[] -} -export const useCollection = ({ items }: UseCollectionProps) => { +export const useCollection = ({ + items, +}: { + items: ProcessedCollectionCardProps[] +}) => { const [appliedFilters, setAppliedFilters] = useState([]) const [searchValue, _setSearchValue] = useState("") diff --git a/packages/components/src/templates/next/layouts/Collection/utils.ts b/packages/components/src/templates/next/layouts/Collection/utils.ts index e359813bb..6e1a486bd 100644 --- a/packages/components/src/templates/next/layouts/Collection/utils.ts +++ b/packages/components/src/templates/next/layouts/Collection/utils.ts @@ -1,5 +1,5 @@ import type { AppliedFilter, Filter as FilterType } from "../../types/Filter" -import type { CollectionCardProps } from "~/interfaces" +import type { ProcessedCollectionCardProps } from "~/interfaces" import { getParsedDate } from "~/utils" const FILTER_ID_CATEGORY = "category" @@ -7,7 +7,7 @@ const FILTER_ID_YEAR = "year" const NO_SPECIFIED_YEAR_FILTER_ID = "not_specified" export const getAvailableFilters = ( - items: CollectionCardProps[], + items: ProcessedCollectionCardProps[], ): FilterType[] => { const categories: Record = {} const years: Record = {} @@ -80,10 +80,10 @@ export const getAvailableFilters = ( } export const getFilteredItems = ( - items: CollectionCardProps[], + items: ProcessedCollectionCardProps[], appliedFilters: AppliedFilter[], searchValue: string, -): CollectionCardProps[] => { +): ProcessedCollectionCardProps[] => { return items.filter((item) => { // Step 1: Filter based on search value if ( @@ -130,7 +130,7 @@ export const getFilteredItems = ( } export const getPaginatedItems = ( - items: CollectionCardProps[], + items: ProcessedCollectionCardProps[], itemsPerPage: number, currPage: number, ) => { @@ -178,6 +178,8 @@ export const updateAppliedFilters = ( } } -export const shouldShowDate = (items: CollectionCardProps[]): boolean => { +export const shouldShowDate = ( + items: ProcessedCollectionCardProps[], +): boolean => { return items.some((item) => item.lastUpdated) } diff --git a/packages/components/src/templates/next/render/index.ts b/packages/components/src/templates/next/render/index.ts index 28646bc15..6593007ad 100644 --- a/packages/components/src/templates/next/render/index.ts +++ b/packages/components/src/templates/next/render/index.ts @@ -1,2 +1,3 @@ export { renderLayout, renderComponent } from "./render" export { renderPageContent } from "./renderPageContent" +export { renderComponentPreviewText } from "./renderComponentPreviewText" diff --git a/packages/components/src/templates/next/render/renderComponentPreviewText.ts b/packages/components/src/templates/next/render/renderComponentPreviewText.ts new file mode 100644 index 000000000..c7e463aef --- /dev/null +++ b/packages/components/src/templates/next/render/renderComponentPreviewText.ts @@ -0,0 +1,100 @@ +import type { OrderedListProps, ProseContent } from "~/interfaces" +import type { IsomerSchema } from "~/types" + +function getTextContentOfProse(content: ProseContent): string { + const values: string[] = [] + + function recursiveSearch( + content: ProseContent | OrderedListProps["content"], + ) { + content?.map((contentBlock) => { + switch (contentBlock.type) { + case "heading": + values.push( + contentBlock.content + ?.map((textBlock) => textBlock.text.trim()) + .join(" ") || "", + ) + break + case "orderedList": + case "unorderedList": + contentBlock.content.map((listItemBlock) => { + recursiveSearch(listItemBlock.content) + }) + break + case "listItem": + recursiveSearch(contentBlock.content) + break + case "paragraph": + contentBlock.content?.map((paragraphContentBlock) => { + switch (paragraphContentBlock.type) { + case "text": + values.push(paragraphContentBlock.text.trim()) + break + case "hardBreak": + break + default: + const exhaustiveCheck: never = paragraphContentBlock + return exhaustiveCheck + } + }) + break + case "table": + values.push(contentBlock.attrs.caption.trim()) + break + case "divider": + break + default: + const exhaustiveCheck: never = contentBlock + return exhaustiveCheck + } + }) + } + + recursiveSearch(content) + return values.join(" ") +} + +function getFilenameFromPath(path: string): string { + return path.split("/").pop() || "" +} + +export function renderComponentPreviewText({ + component, +}: { + component: IsomerSchema["content"][number] +}): string { + switch (component.type) { + case "accordion": + return component.summary + case "callout": + return getTextContentOfProse(component.content.content) + case "hero": + return "" // should not show up in the sidebar + case "iframe": + return "Iframe" // not supported in the sidebar yet + case "image": + return getFilenameFromPath(component.src) + case "infobar": + return component.title + case "infocards": + return component.title + case "infocols": + return component.title + case "infopic": + return component.title + case "contentpic": + const textContentOfProse = getTextContentOfProse( + component.content.content, + ) + return textContentOfProse === "" + ? getFilenameFromPath(component.imageSrc) + : textContentOfProse + case "keystatistics": + return component.title + case "prose": + return getTextContentOfProse(component.content) + default: + return (component as unknown as { type: string }).type || "" + } +}