Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function useConversionModal() {
}

interface ToolbarButtonProps {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
name: string;
label: MessageDescriptor;
isActive: boolean;
Expand Down Expand Up @@ -144,7 +144,7 @@ const ToolbarButton = ({
height={7}
hasRadius
>
<Icon fill={disabled ? 'neutral300' : enabledColor} />
{Icon && <Icon fill={disabled ? 'neutral300' : enabledColor} />}
</FlexButton>
</Toolbar.ToggleItem>
</Tooltip>
Expand All @@ -160,8 +160,7 @@ const BlocksDropdown = () => {
ReturnType<typeof getEntries>
>((currentKeys, entry) => {
const [key, block] = entry;

return block.isInBlocksSelector ? [...currentKeys, key] : currentKeys;
return block?.isInBlocksSelector ? [...currentKeys, key] : currentKeys;
}, []);

const [blockSelected, setBlockSelected] = React.useState<SelectorBlockKey>('paragraph');
Expand Down Expand Up @@ -213,7 +212,7 @@ const BlocksDropdown = () => {
}

// Let the block handle the Slate conversion logic
const maybeRenderModal = blocks[optionKey].handleConvert?.(editor);
const maybeRenderModal = blocks[optionKey]?.handleConvert?.(editor);
handleConversionResult(maybeRenderModal);

setBlockSelected(optionKey);
Expand Down Expand Up @@ -268,7 +267,7 @@ const BlocksDropdown = () => {

// Find the block key that matches the anchor node
const anchorBlockKey = getKeys(blocks).find(
(blockKey) => !Editor.isEditor(selectedNode) && blocks[blockKey].matchNode(selectedNode)
(blockKey) => !Editor.isEditor(selectedNode) && blocks[blockKey]?.matchNode(selectedNode)
);

// Change the value selected in the dropdown if it doesn't match the anchor block key
Expand All @@ -278,15 +277,26 @@ const BlocksDropdown = () => {
}
}, [editor.selection, editor, blocks, blockSelected]);

const Icon = blocks[blockSelected].icon;
React.useEffect(() => {
// If the selected block is not in the list of blocks to include, change the selected block to the first one
if (blockKeysToInclude.length > 0 && !blockKeysToInclude.includes(blockSelected)) {
setBlockSelected(blockKeysToInclude[0]);
}
}, [blockKeysToInclude, blockSelected]);

if (!blocks[blockSelected]) {
return null;
}

const Icon = blocks[blockSelected]!.icon;

return (
<>
<SelectWrapper>
<SingleSelect
startIcon={<Icon />}
startIcon={Icon && <Icon />}
onChange={handleSelect}
placeholder={formatMessage(blocks[blockSelected].label)}
placeholder={formatMessage(blocks[blockSelected]!.label)}
value={blockSelected}
onCloseAutoFocus={preventSelectFocus}
aria-label={formatMessage({
Expand All @@ -299,8 +309,8 @@ const BlocksDropdown = () => {
<BlockOption
key={key}
value={key}
label={blocks[key].label}
icon={blocks[key].icon}
label={blocks[key]!.label}
icon={blocks[key]!.icon}
blockSelected={blockSelected}
/>
))}
Expand All @@ -313,7 +323,7 @@ const BlocksDropdown = () => {

interface BlockOptionProps {
value: string;
icon: React.ComponentType<React.SVGProps<SVGElement>>;
icon?: React.ComponentType<React.SVGProps<SVGElement>>;
label: MessageDescriptor;
blockSelected: string;
}
Expand All @@ -325,7 +335,7 @@ const BlockOption = ({ value, icon: Icon, label, blockSelected }: BlockOptionPro

return (
<SingleSelectOption
startIcon={<Icon fill={isSelected ? 'primary600' : 'neutral600'} />}
startIcon={Icon && <Icon fill={isSelected ? 'primary600' : 'neutral600'} />}
value={value}
>
{formatMessage(label)}
Expand Down Expand Up @@ -412,7 +422,7 @@ const ListButton = ({ block, format }: ListButtonProps) => {

if (!currentListEntry) {
// If selection is not a list then convert it to list
blocks[`list-${format}`].handleConvert!(editor);
blocks[`list-${format}`]?.handleConvert!(editor);
return;
}

Expand All @@ -425,11 +435,15 @@ const ListButton = ({ block, format }: ListButtonProps) => {
Transforms.setNodes(editor, { format }, { at: currentListPath });
} else {
// Format is same, convert selected list-item to paragraph
blocks['paragraph'].handleConvert!(editor);
blocks['paragraph']?.handleConvert!(editor);
}
}
};

if (!block) {
return <></>;
}

return (
<ToolbarButton
icon={block.icon}
Expand Down Expand Up @@ -561,8 +575,12 @@ const BlocksToolbar = () => {
<Separator />
<Toolbar.ToggleGroup type="single" asChild>
<Flex gap={1}>
<ListButton block={blocks['list-unordered']} format="unordered" />
<ListButton block={blocks['list-ordered']} format="ordered" />
{blocks['list-unordered'] && (
<ListButton block={blocks['list-unordered']} format="unordered" />
)}
{blocks['list-ordered'] && (
<ListButton block={blocks['list-ordered']} format="ordered" />
)}
</Flex>
</Toolbar.ToggleGroup>
</ToolbarWrapper>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { codeBlocks } from './Blocks/Code';
import { headingBlocks } from './Blocks/Heading';
import { imageBlocks } from './Blocks/Image';
import { linkBlocks } from './Blocks/Link';
import { listBlocks } from './Blocks/List';
import { paragraphBlocks } from './Blocks/Paragraph';
import { quoteBlocks } from './Blocks/Quote';
import { BlocksStore } from './BlocksEditor';

const defaultBlocksStore: BlocksStore = {
...paragraphBlocks,
...headingBlocks,
...listBlocks,
...linkBlocks,
...imageBlocks,
...quoteBlocks,
...codeBlocks,
};

export { defaultBlocksStore };
112 changes: 112 additions & 0 deletions packages/core/content-manager/admin/src/tests/content-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable check-file/filename-naming-convention */
import { JSX } from 'react';

import { ContentManagerPlugin, DocumentActionComponent, PanelComponent } from '../content-manager';

jest.mock('../pages/EditView/components/Panels', () => ({
Expand Down Expand Up @@ -41,10 +43,12 @@ describe('content-manager', () => {
"addDocumentAction": [Function],
"addDocumentHeaderAction": [Function],
"addEditViewSidePanel": [Function],
"addRichTextBlocks": [Function],
"getBulkActions": [Function],
"getDocumentActions": [Function],
"getEditViewSidePanels": [Function],
"getHeaderActions": [Function],
"getRichTextBlocks": [Function],
},
"id": "content-manager",
"injectionZones": {
Expand Down Expand Up @@ -77,10 +81,12 @@ describe('content-manager', () => {
"addDocumentAction",
"addDocumentHeaderAction",
"addEditViewSidePanel",
"addRichTextBlocks",
"getBulkActions",
"getDocumentActions",
"getEditViewSidePanels",
"getHeaderActions",
"getRichTextBlocks",
]
`);
});
Expand Down Expand Up @@ -261,4 +267,110 @@ describe('content-manager', () => {
);
});
});

describe('addRichTextBlocks', () => {
it('should let users add a new block with objects keys', () => {
const plugin = new ContentManagerPlugin();

plugin.addRichTextBlocks({
customBlock: {
isInBlocksSelector: true,
renderElement: () => null as unknown as JSX.Element,
matchNode: (node) => node.type === 'customBlock',
label: { defaultMessage: 'Custom Block' },
},
});

expect(plugin.richTextBlocksStore).toHaveProperty('customBlock');
});

it('should let users add a new block with a function', () => {
const plugin = new ContentManagerPlugin();

plugin.addRichTextBlocks((prev) => ({
...prev,
customBlock: {
isInBlocksSelector: true,
renderElement: () => null as unknown as JSX.Element,
matchNode: (node) => node.type === 'customBlock',
label: { defaultMessage: 'Custom Block' },
},
}));

expect(plugin.richTextBlocksStore).toHaveProperty('customBlock');
});

it('should let users remove a block with a function', () => {
const plugin = new ContentManagerPlugin();

plugin.addRichTextBlocks({
customBlock: {
isInBlocksSelector: true,
renderElement: () => null as unknown as JSX.Element,
matchNode: (node) => node.type === 'customBlock',
label: { defaultMessage: 'Custom Block' },
},
});

expect(plugin.richTextBlocksStore).toHaveProperty('customBlock');

plugin.addRichTextBlocks((prev) => {
delete prev.customBlock;
return prev;
});

expect(plugin.richTextBlocksStore).not.toHaveProperty('customBlock');
});

it('should let users change an existing block', () => {
const plugin = new ContentManagerPlugin();

plugin.addRichTextBlocks({
customBlock: {
isInBlocksSelector: true,
renderElement: () => null as unknown as JSX.Element,
matchNode: (node) => node.type === 'customBlock',
label: { defaultMessage: 'Custom Block' },
},
});

expect(plugin.richTextBlocksStore.customBlock).toHaveProperty('label', {
defaultMessage: 'Custom Block',
});

plugin.addRichTextBlocks((prev) => ({
...prev,
customBlock: {
...prev.customBlock,
label: { defaultMessage: 'New Custom Block' },
},
}));

expect(plugin.richTextBlocksStore.customBlock).toHaveProperty('label', {
defaultMessage: 'New Custom Block',
});
});

it('should throw an error if you pass an invalid object', () => {
const plugin = new ContentManagerPlugin();

expect(() =>
// @ts-expect-error – testing it fails.
plugin.addRichTextBlocks('I will break')
).toThrowErrorMatchingInlineSnapshot(
`"Expected the \`blocks\` passed to \`addRichTextBlocks\` to be an object or a function, but received string"`
);
});

it('should throw an error if you pass an invalid function', () => {
const plugin = new ContentManagerPlugin();

expect(() =>
// @ts-expect-error – testing it fails.
plugin.addRichTextBlocks(() => 'I will break')
).toThrowErrorMatchingInlineSnapshot(
`"Expected the \`blocks\` passed to \`addRichTextBlocks\` to be an object or a function, but received string"`
);
});
});
});