From a22738877a635ecaeeac0cc34d796c4ac5059c05 Mon Sep 17 00:00:00 2001 From: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> Date: Thu, 14 Dec 2023 13:56:32 +0100 Subject: [PATCH] Custom block categories (#1504) Allows specifying custom block categories in application code. --- .changeset/two-carpets-run.md | 37 ++++++++ .../common/blocks/customBlockCategories.tsx | 11 +++ .../src/pages/blocks/FullWidthImageBlock.tsx | 36 ++++--- demo/admin/src/pages/blocks/TwoListsBlock.tsx | 31 ++++--- .../src/blocks/common/AddBlockDrawer.tsx | 93 +++++++++++++++---- .../blocks/factories/createColumnsBlock.tsx | 4 +- .../blocks/factories/createCompositeBlock.tsx | 4 +- .../src/blocks/factories/createOneOfBlock.tsx | 4 +- .../admin/blocks-admin/src/blocks/types.tsx | 6 +- packages/admin/blocks-admin/src/index.ts | 1 + 10 files changed, 174 insertions(+), 53 deletions(-) create mode 100644 .changeset/two-carpets-run.md create mode 100644 demo/admin/src/common/blocks/customBlockCategories.tsx diff --git a/.changeset/two-carpets-run.md b/.changeset/two-carpets-run.md new file mode 100644 index 0000000000..1b73d39ca8 --- /dev/null +++ b/.changeset/two-carpets-run.md @@ -0,0 +1,37 @@ +--- +"@comet/blocks-admin": minor +--- + +Add support for custom block categories + +Allows specifying custom block categories in application code. + +**Example:** + +In `src/common/blocks/customBlockCategories.tsx`: + +```tsx +import { BlockCategory, CustomBlockCategory } from "@comet/blocks-admin"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +const productsBlockCategory: CustomBlockCategory = { + id: "Products", + label: , + // Specify where category will be shown in drawer + insertBefore: BlockCategory.Teaser, +}; + +export { productsBlockCategory }; +``` + +In `src/documents/pages/blocks/MyBlock.tsx`: + +```tsx +import { productsBlockCategory } from "@src/common/blocks/customBlockCategories"; + +const MyBlock: BlockInterface = { + category: productsBlockCategory, + ... +}; +``` diff --git a/demo/admin/src/common/blocks/customBlockCategories.tsx b/demo/admin/src/common/blocks/customBlockCategories.tsx new file mode 100644 index 0000000000..8e323212af --- /dev/null +++ b/demo/admin/src/common/blocks/customBlockCategories.tsx @@ -0,0 +1,11 @@ +import { BlockCategory } from "@comet/blocks-admin"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +const customBlockCategory = { + id: "Custom", + label: , + insertBefore: BlockCategory.Media, +}; + +export { customBlockCategory }; diff --git a/demo/admin/src/pages/blocks/FullWidthImageBlock.tsx b/demo/admin/src/pages/blocks/FullWidthImageBlock.tsx index a9a9e10198..f038cc485e 100644 --- a/demo/admin/src/pages/blocks/FullWidthImageBlock.tsx +++ b/demo/admin/src/pages/blocks/FullWidthImageBlock.tsx @@ -1,6 +1,7 @@ import { messages } from "@comet/admin"; -import { BlockCategory, createCompositeBlock, createOptionalBlock } from "@comet/blocks-admin"; +import { createCompositeBlock, createOptionalBlock } from "@comet/blocks-admin"; import { DamImageBlock } from "@comet/cms-admin"; +import { customBlockCategory } from "@src/common/blocks/customBlockCategories"; import { RichTextBlock } from "@src/common/blocks/RichTextBlock"; import * as React from "react"; import { FormattedMessage } from "react-intl"; @@ -9,19 +10,24 @@ const FullWidthImageContentBlock = createOptionalBlock(RichTextBlock, { title: , }); -export const FullWidthImageBlock = createCompositeBlock({ - name: "FullWidthImage", - displayName: , - category: BlockCategory.Media, - blocks: { - image: { - block: DamImageBlock, - title: , - paper: true, - }, - content: { - block: FullWidthImageContentBlock, - title: , +export const FullWidthImageBlock = createCompositeBlock( + { + name: "FullWidthImage", + displayName: , + blocks: { + image: { + block: DamImageBlock, + title: , + paper: true, + }, + content: { + block: FullWidthImageContentBlock, + title: , + }, }, }, -}); + (block) => { + block.category = customBlockCategory; + return block; + }, +); diff --git a/demo/admin/src/pages/blocks/TwoListsBlock.tsx b/demo/admin/src/pages/blocks/TwoListsBlock.tsx index 71d6aed6b0..1d42d9b793 100644 --- a/demo/admin/src/pages/blocks/TwoListsBlock.tsx +++ b/demo/admin/src/pages/blocks/TwoListsBlock.tsx @@ -1,4 +1,5 @@ import { createCompositeBlock, createListBlock } from "@comet/blocks-admin"; +import { customBlockCategory } from "@src/common/blocks/customBlockCategories"; import { HeadlineBlock } from "@src/common/blocks/HeadlineBlock"; const TwoListsListBlock = createListBlock({ @@ -6,17 +7,23 @@ const TwoListsListBlock = createListBlock({ block: HeadlineBlock, }); -export const TwoListsBlock = createCompositeBlock({ - name: "TwoLists", - displayName: "Two Lists", - blocks: { - list1: { - block: TwoListsListBlock, - title: "List 1", - }, - list2: { - block: TwoListsListBlock, - title: "List 2", +export const TwoListsBlock = createCompositeBlock( + { + name: "TwoLists", + displayName: "Two Lists", + blocks: { + list1: { + block: TwoListsListBlock, + title: "List 1", + }, + list2: { + block: TwoListsListBlock, + title: "List 2", + }, }, }, -}); + (block) => { + block.category = customBlockCategory; + return block; + }, +); diff --git a/packages/admin/blocks-admin/src/blocks/common/AddBlockDrawer.tsx b/packages/admin/blocks-admin/src/blocks/common/AddBlockDrawer.tsx index 6c46a49a26..f30f1065d1 100644 --- a/packages/admin/blocks-admin/src/blocks/common/AddBlockDrawer.tsx +++ b/packages/admin/blocks-admin/src/blocks/common/AddBlockDrawer.tsx @@ -19,12 +19,12 @@ import { styled } from "@mui/material/styles"; import * as React from "react"; import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl"; -import { BlockCategory, blockCategoryLabels, BlockInterface } from "../types"; +import { BlockCategory, blockCategoryLabels, BlockInterface, CustomBlockCategory } from "../types"; type BlockType = string; interface Category { - blockCategory: BlockCategory; + id: string; label: React.ReactNode; blocks: Array<[BlockType, BlockInterface]>; } @@ -38,27 +38,76 @@ interface Props { export function AddBlockDrawer({ open, onClose, blocks, onAddNewBlock }: Props): React.ReactElement { const intl = useIntl(); - const [categories, setCategories] = React.useState([]); const [searchValue, setSearchValue] = React.useState(""); const [addAndEdit, setAddAndEdit] = useStoredState("addAndEdit", true); - React.useEffect(() => { - setCategories( - (Object.keys(BlockCategory) as BlockCategory[]).map((currentBlockCategory) => { - const blocksForCategory = Object.entries(blocks).filter(([, block]) => { - const formattedDisplayName = - typeof block.displayName === "string" - ? (block.displayName as string) - : intl.formatMessage((block.displayName as React.ReactElement).props); + const categories = React.useMemo(() => { + const categories: Category[] = []; + const categoriesOrder = Object.keys(BlockCategory); - return ( - block.category === currentBlockCategory && formattedDisplayName.toLocaleLowerCase().includes(searchValue.toLocaleLowerCase()) + for (const [type, block] of Object.entries(blocks)) { + let blockName: string; + + if (typeof block.displayName === "string") { + blockName = block.displayName; + } else if (isFormattedMessage(block.displayName)) { + blockName = intl.formatMessage(block.displayName.props); + } else { + throw new TypeError("Block displayName must be either a string or a FormattedMessage"); + } + + if (!blockName.toLocaleLowerCase().includes(searchValue.toLocaleLowerCase())) { + continue; + } + + let id: string; + let label: React.ReactNode; + + if (isCustomBlockCategory(block.category)) { + if (block.category.id in BlockCategory) { + throw new Error( + `Custom block category "${block.category.id}" cannot override default block category BlockCategory.${block.category.id}`, ); - }); + } + + id = block.category.id; + label = block.category.label; + + if (block.category.insertBefore) { + const insertBeforeIndex = categoriesOrder.indexOf(block.category.insertBefore); + + if (categoriesOrder.includes(id)) { + const hasDifferentInsertBefore = insertBeforeIndex - 1 !== categoriesOrder.indexOf(id); + + if (hasDifferentInsertBefore) { + throw new Error(`Custom block category "${id}" has different "insertBefore" values`); + } + } else { + categoriesOrder.splice(insertBeforeIndex, 0, id); + } + } else { + if (!categoriesOrder.includes(id)) { + categoriesOrder.push(id); + } + } + } else { + id = block.category; + label = blockCategoryLabels[block.category]; + } + + let category = categories.find((category) => category.id === id); + + if (!category) { + category = { id, label, blocks: [] }; + categories.push(category); + } + + category.blocks.push([type, block]); + } - return { blockCategory: currentBlockCategory, label: blockCategoryLabels[currentBlockCategory], blocks: blocksForCategory }; - }), - ); + categories.sort((a, b) => categoriesOrder.indexOf(a.id) - categoriesOrder.indexOf(b.id)); + + return categories; }, [intl, blocks, searchValue]); const handleListItemClick = (type: string) => { @@ -122,7 +171,7 @@ export function AddBlockDrawer({ open, onClose, blocks, onAddNewBlock }: Props): } return ( - + {category.label} @@ -146,6 +195,14 @@ export function AddBlockDrawer({ open, onClose, blocks, onAddNewBlock }: Props): ); } +function isCustomBlockCategory(category: BlockCategory | CustomBlockCategory): category is CustomBlockCategory { + return typeof category === "object"; +} + +function isFormattedMessage(node: React.ReactNode): node is React.ReactElement { + return React.isValidElement(node) && node.type === FormattedMessage; +} + const Content = styled(DialogContent)` width: 380px; `; diff --git a/packages/admin/blocks-admin/src/blocks/factories/createColumnsBlock.tsx b/packages/admin/blocks-admin/src/blocks/factories/createColumnsBlock.tsx index 008f2cf072..fa374878f1 100644 --- a/packages/admin/blocks-admin/src/blocks/factories/createColumnsBlock.tsx +++ b/packages/admin/blocks-admin/src/blocks/factories/createColumnsBlock.tsx @@ -17,7 +17,7 @@ import { AdminComponentStickyFooter } from "../common/AdminComponentStickyFooter import { BlockRow } from "../common/blockRow/BlockRow"; import { createBlockSkeleton } from "../helpers/createBlockSkeleton"; import { deduplicateBlockDependencies } from "../helpers/deduplicateBlockDependencies"; -import { BlockCategory, BlockDependency, BlockInputApi, BlockInterface, DispatchSetStateAction, PreviewContent } from "../types"; +import { BlockCategory, BlockDependency, BlockInputApi, BlockInterface, CustomBlockCategory, DispatchSetStateAction, PreviewContent } from "../types"; import { resolveNewState } from "../utils"; import { FinalFormColumnsSelect } from "./columnsBlock/FinalFormColumnsSelect"; import { FinalFormLayoutSelect } from "./columnsBlock/FinalFormLayoutSelect"; @@ -54,7 +54,7 @@ interface ColumnsBlockState { interface CreateColumnsBlockOptions { name: string; displayName: React.ReactNode; - category?: BlockCategory; + category?: BlockCategory | CustomBlockCategory; contentBlock: T; layouts: ColumnsBlockLayout[]; } diff --git a/packages/admin/blocks-admin/src/blocks/factories/createCompositeBlock.tsx b/packages/admin/blocks-admin/src/blocks/factories/createCompositeBlock.tsx index 6e4c2dfe1c..d32197e3be 100644 --- a/packages/admin/blocks-admin/src/blocks/factories/createCompositeBlock.tsx +++ b/packages/admin/blocks-admin/src/blocks/factories/createCompositeBlock.tsx @@ -14,7 +14,7 @@ import { BlockInterfaceWithOptions } from "../helpers/composeBlocks/types"; import { normalizedBlockConfig } from "../helpers/composeBlocks/utils"; import { createBlockSkeleton } from "../helpers/createBlockSkeleton"; import { isBlockInterface } from "../helpers/isBlockInterface"; -import { BlockCategory, BlockInputApi, BlockInterface, BlockOutputApi, BlockState } from "../types"; +import { BlockCategory, BlockInputApi, BlockInterface, BlockOutputApi, BlockState, CustomBlockCategory } from "../types"; interface BlockConfiguration { title?: React.ReactNode; @@ -41,7 +41,7 @@ interface CreateCompositeBlockOptionsBase { /** * @deprecated Use override instead to adapt the factored block */ - category?: BlockCategory; + category?: BlockCategory | CustomBlockCategory; adminLayout?: "stacked"; blocks: Record; } diff --git a/packages/admin/blocks-admin/src/blocks/factories/createOneOfBlock.tsx b/packages/admin/blocks-admin/src/blocks/factories/createOneOfBlock.tsx index 7e81c4da33..ca2d22e2ea 100644 --- a/packages/admin/blocks-admin/src/blocks/factories/createOneOfBlock.tsx +++ b/packages/admin/blocks-admin/src/blocks/factories/createOneOfBlock.tsx @@ -12,7 +12,7 @@ import { parallelAsyncEvery } from "../../utils/parallelAsyncEvery"; import { useAdminComponentPaper } from "../common/AdminComponentPaper"; import { HiddenInSubroute } from "../common/HiddenInSubroute"; import { createBlockSkeleton } from "../helpers/createBlockSkeleton"; -import { BlockCategory, BlockInterface, BlockState, DispatchSetStateAction, PreviewStateInterface } from "../types"; +import { BlockCategory, BlockInterface, BlockState, CustomBlockCategory, DispatchSetStateAction, PreviewStateInterface } from "../types"; import { resolveNewState } from "../utils"; interface OneOfBlockItem { @@ -57,7 +57,7 @@ export interface CreateOneOfBlockOptions { name: string; displayName?: React.ReactNode; supportedBlocks: Record; - category?: BlockCategory; + category?: BlockCategory | CustomBlockCategory; variant?: "select" | "radio" | "toggle"; allowEmpty?: T; } diff --git a/packages/admin/blocks-admin/src/blocks/types.tsx b/packages/admin/blocks-admin/src/blocks/types.tsx index 38f3870500..8f5cdb6bb5 100644 --- a/packages/admin/blocks-admin/src/blocks/types.tsx +++ b/packages/admin/blocks-admin/src/blocks/types.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, MessageDescriptor } from "react-intl"; export type SetStateFn = (prevState: S) => S; export type SetStateAction = S | SetStateFn; @@ -110,7 +110,7 @@ export interface BlockInterface< > extends AnonymousBlockInterface { name: string; displayName: React.ReactNode; - category: BlockCategory; + category: BlockCategory | CustomBlockCategory; } export interface RootBlockInterface< @@ -150,6 +150,8 @@ export enum BlockCategory { Other = "Other", } +export type CustomBlockCategory = { id: string; label: string | React.ReactElement; insertBefore?: BlockCategory }; + export const blockCategoryLabels = { [BlockCategory.TextAndContent]: , [BlockCategory.Media]: , diff --git a/packages/admin/blocks-admin/src/index.ts b/packages/admin/blocks-admin/src/index.ts index c1cf70e270..076b27fd1f 100644 --- a/packages/admin/blocks-admin/src/index.ts +++ b/packages/admin/blocks-admin/src/index.ts @@ -49,6 +49,7 @@ export type { SetStateAction, SetStateFn, } from "./blocks/types"; +export type { CustomBlockCategory } from "./blocks/types"; export { BlockCategory, blockCategoryLabels } from "./blocks/types"; export { resolveNewState } from "./blocks/utils"; export { YouTubeVideoBlock } from "./blocks/YouTubeVideoBlock";