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";