Add blocks editor

This commit is contained in:
Daniel Espino 2026-05-21 14:56:45 +02:00
parent fe7b6d89eb
commit bf1d189c79
5 changed files with 1669 additions and 11 deletions

View file

@ -16,6 +16,9 @@ import {translateBlockKit} from 'components/block_renderer/translation/block_kit
import {translateMMBlocks} from 'components/block_renderer/translation/mm_block';
import PostContext from 'components/post_view/post_context';
import {type BlockPath, serializeMmBlocks} from './mm_blocks_editor_utils';
import MmBlocksHierarchyEditor from './mm_blocks_hierarchy_editor';
import './component_library.scss';
type InputMode = 'mm_blocks' | 'attachments' | 'block_kit' | 'adaptive_cards';
@ -164,20 +167,31 @@ const MmBlocksComponentLibrary = ({
}: Props) => {
const [inputMode, setInputMode] = useState<InputMode>('mm_blocks');
const [drafts, setDrafts] = useState<Record<InputMode, string>>(() => ({...INITIAL_DRAFTS}));
const [selectedBlockPath, setSelectedBlockPath] = useState<BlockPath | null>(null);
const jsonText = drafts[inputMode];
const parsed = useMemo(() => parsePayload(jsonText, inputMode), [jsonText, inputMode]);
const showMmBlocksEditor = inputMode === 'mm_blocks' && parsed.ok;
const onChangeMode = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setInputMode(e.target.value as InputMode);
setSelectedBlockPath(null);
}, []);
const onChangeJson = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setDrafts((d) => ({...d, [inputMode]: value}));
if (inputMode === 'mm_blocks') {
setSelectedBlockPath(null);
}
}, [inputMode]);
const onHierarchyBlocksChange = useCallback((blocks: MmBlock[]) => {
setDrafts((d) => ({...d, mm_blocks: serializeMmBlocks(blocks)}));
}, []);
const onAction = useCallback((actionId: string, selectedOption?: string, query?: Record<string, string>, attachmentCookie?: string) => {
const parts = [
`action_id: ${actionId}`,
@ -218,17 +232,27 @@ const MmBlocksComponentLibrary = ({
{modeOptions}
</select>
</label>
<label className='clInput'>
{'JSON: '}
<textarea
className='clJsonEditor'
spellCheck={false}
value={jsonText}
onChange={onChangeJson}
rows={16}
aria-label='Interactive message JSON'
/>
</label>
<div className={showMmBlocksEditor ? 'clMmBlocksEditorLayout' : undefined}>
<label className={classNames('clInput', showMmBlocksEditor && 'clMmBlocksEditorLayout__json')}>
{'JSON: '}
<textarea
className='clJsonEditor'
spellCheck={false}
value={jsonText}
onChange={onChangeJson}
rows={16}
aria-label='Interactive message JSON'
/>
</label>
{showMmBlocksEditor && (
<MmBlocksHierarchyEditor
blocks={parsed.blocks}
selectedPath={selectedBlockPath}
onSelectPath={setSelectedBlockPath}
onChangeBlocks={onHierarchyBlocksChange}
/>
)}
</div>
{!parsed.ok && (
<div
className='clJsonError'

View file

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MmBlock} from '@mattermost/types/mm_blocks';
import {
createDefaultBlock,
getBlockAt,
insertBlockAt,
moveBlockAt,
parsePathKey,
pathKey,
remapPathAfterMove,
removeBlockAt,
sameParentList,
serializeMmBlocks,
updateBlockAt,
} from './mm_blocks_editor_utils';
describe('mm_blocks_editor_utils', () => {
const sample: MmBlock[] = [
{type: 'text', text: 'Hello'},
{
type: 'container',
content: [{type: 'divider'}],
},
];
test('getBlockAt resolves nested path', () => {
const path = [{list: 'root', index: 1}, {list: 'content', index: 0}] as const;
expect(getBlockAt(sample, [...path])?.type).toBe('divider');
});
test('updateBlockAt changes block text', () => {
const path = [{list: 'root' as const, index: 0}];
const next = updateBlockAt(sample, path, {type: 'text', text: 'Updated'});
expect(getBlockAt(next, path)).toEqual({type: 'text', text: 'Updated'});
});
test('removeBlockAt removes root block', () => {
const path = [{list: 'root' as const, index: 0}];
const next = removeBlockAt(sample, path);
expect(next).toHaveLength(1);
expect(next[0].type).toBe('container');
});
test('insertBlockAt adds sibling', () => {
const path = [{list: 'root' as const, index: 0}];
const next = insertBlockAt(sample, path, {type: 'divider'}, 'sibling');
expect(next).toHaveLength(3);
expect(next[1].type).toBe('divider');
});
test('serializeMmBlocks produces formatted JSON', () => {
expect(serializeMmBlocks([{type: 'text', text: 'x'}])).toContain('\n');
});
test('pathKey is stable', () => {
expect(pathKey([{list: 'root', index: 0}, {list: 'content', index: 1}])).toBe('root:0/content:1');
});
test('createDefaultBlock returns valid shapes', () => {
expect(createDefaultBlock('column_set').type).toBe('column_set');
});
test('parsePathKey round-trips pathKey', () => {
const path = [{list: 'root', index: 1}, {list: 'content', index: 0}] as const;
expect(parsePathKey(pathKey([...path]))).toEqual([...path]);
});
test('sameParentList detects siblings', () => {
expect(sameParentList(
[{list: 'root', index: 0}],
[{list: 'root', index: 1}],
)).toBe(true);
expect(sameParentList(
[{list: 'root', index: 0}, {list: 'content', index: 0}],
[{list: 'root', index: 0}, {list: 'content', index: 1}],
)).toBe(true);
expect(sameParentList(
[{list: 'root', index: 0}],
[{list: 'root', index: 0}, {list: 'content', index: 0}],
)).toBe(false);
});
test('moveBlockAt reorders within a list', () => {
const blocks: MmBlock[] = [
{type: 'text', text: 'A'},
{type: 'text', text: 'B'},
{type: 'text', text: 'C'},
];
const from = [{list: 'root' as const, index: 0}];
const next = moveBlockAt(blocks, from, 2);
expect(next.map((b) => (b as {text: string}).text)).toEqual(['B', 'C', 'A']);
});
test('remapPathAfterMove updates moved and shifted paths', () => {
const from = [{list: 'root' as const, index: 0}];
expect(remapPathAfterMove(from, from, 2)).toEqual([{list: 'root', index: 2}]);
expect(remapPathAfterMove([{list: 'root', index: 2}], from, 2)).toEqual([{list: 'root', index: 1}]);
});
});

View file

@ -0,0 +1,620 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {
MmBlock,
MmButtonStyle,
MmColumnBlock,
MmContainerBackground,
MmContainerGap,
MmContainerMaxHeight,
MmImageSize,
MmStaticSelectOption,
MmTextSize,
} from '@mattermost/types/mm_blocks';
/** Identifies which child array a path segment refers to. */
export type ChildListKey = 'root' | 'content' | 'items' | 'columns' | 'header';
export type PathSegment = {
list: ChildListKey;
index: number;
};
export type BlockPath = PathSegment[];
export type AddBlockTarget = 'sibling' | 'child';
export type BlockTypeId =
| 'text'
| 'divider'
| 'button'
| 'static_select'
| 'image'
| 'column'
| 'column_set'
| 'container'
| 'collapsible';
export type PropertyFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'json';
export type PropertyField = {
key: string;
label: string;
type: PropertyFieldType;
options?: string[];
placeholder?: string;
};
export const ROOT_ADDABLE_TYPES: BlockTypeId[] = [
'text',
'divider',
'button',
'static_select',
'image',
'container',
'column_set',
'collapsible',
];
export const COLUMN_SET_ADDABLE_TYPES: BlockTypeId[] = ['column'];
const BLOCK_TYPE_LABELS: Record<BlockTypeId, string> = {
text: 'Text',
divider: 'Divider',
button: 'Button',
static_select: 'Static select',
image: 'Image',
column: 'Column',
column_set: 'Column set',
container: 'Container',
collapsible: 'Collapsible',
};
export function blockTypeLabel(type: BlockTypeId): string {
return BLOCK_TYPE_LABELS[type];
}
export function createDefaultBlock(type: BlockTypeId): MmBlock {
switch (type) {
case 'text':
return {type: 'text', text: 'New text'};
case 'divider':
return {type: 'divider'};
case 'button':
return {type: 'button', text: 'Button', action_id: 'action_id', style: 'default'};
case 'static_select':
return {
type: 'static_select',
action_id: 'select_action',
placeholder: 'Choose an option',
options: [
{text: 'Option A', value: 'a'},
{text: 'Option B', value: 'b'},
],
};
case 'image':
return {
type: 'image',
url: 'https://example.com/image.png',
alt_text: 'Image description',
};
case 'column':
return {
type: 'column',
items: [{type: 'text', text: 'Column content'}],
};
case 'column_set':
return {
type: 'column_set',
columns: [
{
type: 'column',
items: [{type: 'text', text: 'Column 1'}],
},
{
type: 'column',
items: [{type: 'text', text: 'Column 2'}],
},
],
};
case 'container':
return {
type: 'container',
content: [{type: 'text', text: 'Container content'}],
};
case 'collapsible':
return {
type: 'collapsible',
header: [{type: 'text', text: 'Header'}],
content: [{type: 'text', text: 'Collapsed content'}],
};
default:
return {type: 'text', text: 'New text'};
}
}
export function serializeMmBlocks(blocks: MmBlock[]): string {
return JSON.stringify(blocks, null, 2);
}
function getChildList(block: MmBlock | MmColumnBlock, list: ChildListKey): MmBlock[] | MmColumnBlock[] | null {
switch (block.type) {
case 'container':
if (list === 'content') {
return block.content;
}
return null;
case 'column':
if (list === 'items') {
return block.items;
}
return null;
case 'column_set':
if (list === 'columns') {
return block.columns;
}
return null;
case 'collapsible':
if (list === 'header') {
return block.header;
}
if (list === 'content') {
return block.content;
}
return null;
default:
return null;
}
}
export function getBlockAt(root: MmBlock[], path: BlockPath): MmBlock | MmColumnBlock | null {
if (path.length === 0) {
return null;
}
let list: Array<MmBlock | MmColumnBlock> = root;
let block: MmBlock | MmColumnBlock | null = null;
for (const segment of path) {
if (segment.list === 'root') {
block = list[segment.index] ?? null;
} else if (block) {
const childList = getChildList(block, segment.list);
if (!childList) {
return null;
}
list = childList;
block = list[segment.index] ?? null;
} else {
return null;
}
}
return block;
}
export function getParentContext(
root: MmBlock[],
path: BlockPath,
): {list: MmBlock[] | MmColumnBlock[]; index: number; parentBlock: MmBlock | MmColumnBlock | null} | null {
if (path.length === 0) {
return null;
}
const last = path[path.length - 1];
if (path.length === 1) {
return {list: root, index: last.index, parentBlock: null};
}
const parentPath = path.slice(0, -1);
const parentBlock = getBlockAt(root, parentPath);
if (!parentBlock || last.list === 'root') {
return null;
}
const list = getChildList(parentBlock, last.list);
if (!list) {
return null;
}
return {list, index: last.index, parentBlock};
}
export function cloneBlocks(blocks: MmBlock[]): MmBlock[] {
return JSON.parse(JSON.stringify(blocks)) as MmBlock[];
}
export function updateBlockAt(root: MmBlock[], path: BlockPath, next: MmBlock | MmColumnBlock): MmBlock[] {
const draft = cloneBlocks(root);
const ctx = getParentContext(draft, path);
if (!ctx) {
return draft;
}
ctx.list[ctx.index] = next as MmBlock & MmColumnBlock;
return draft;
}
export function removeBlockAt(root: MmBlock[], path: BlockPath): MmBlock[] {
const draft = cloneBlocks(root);
const ctx = getParentContext(draft, path);
if (!ctx) {
return draft;
}
ctx.list.splice(ctx.index, 1);
return draft;
}
export function insertBlockAt(
root: MmBlock[],
path: BlockPath,
block: MmBlock | MmColumnBlock,
target: AddBlockTarget,
): MmBlock[] {
const draft = cloneBlocks(root);
if (target === 'sibling') {
const ctx = getParentContext(draft, path);
if (!ctx) {
return draft;
}
ctx.list.splice(ctx.index + 1, 0, block as MmBlock & MmColumnBlock);
return draft;
}
const current = getBlockAt(draft, path);
if (!current) {
return draft;
}
const childListKey = defaultChildListKey(current);
if (!childListKey) {
return draft;
}
const list = getChildList(current, childListKey);
if (!list) {
return draft;
}
list.push(block as MmBlock & MmColumnBlock);
return draft;
}
function defaultChildListKey(block: MmBlock | MmColumnBlock): ChildListKey | null {
switch (block.type) {
case 'container':
return 'content';
case 'column':
return 'items';
case 'column_set':
return 'columns';
case 'collapsible':
return 'content';
default:
return null;
}
}
export function canAddChild(block: MmBlock | MmColumnBlock): boolean {
return defaultChildListKey(block) !== null;
}
export function addableTypesForList(list: ChildListKey): BlockTypeId[] {
if (list === 'columns') {
return COLUMN_SET_ADDABLE_TYPES;
}
return ROOT_ADDABLE_TYPES;
}
export function pathKey(path: BlockPath): string {
return path.map((s) => `${s.list}:${s.index}`).join('/');
}
export function parsePathKey(key: string): BlockPath | null {
if (!key) {
return null;
}
const segments: BlockPath = [];
for (const part of key.split('/')) {
const colon = part.indexOf(':');
if (colon === -1) {
return null;
}
const list = part.slice(0, colon) as ChildListKey;
const index = Number.parseInt(part.slice(colon + 1), 10);
if (!Number.isFinite(index) || index < 0) {
return null;
}
segments.push({list, index});
}
return segments.length > 0 ? segments : null;
}
export function parentPathKey(path: BlockPath): string {
if (path.length <= 1) {
return '';
}
return pathKey(path.slice(0, -1));
}
/** True when both paths refer to siblings in the same array (same parent, same list key). */
export function sameParentList(a: BlockPath, b: BlockPath): boolean {
if (a.length === 0 || b.length === 0) {
return false;
}
const aLast = a[a.length - 1];
const bLast = b[b.length - 1];
if (aLast.list !== bLast.list) {
return false;
}
if (a.length === 1 && b.length === 1) {
return aLast.list === 'root' && bLast.list === 'root';
}
if (a.length !== b.length) {
return false;
}
return parentPathKey(a) === parentPathKey(b);
}
export function moveBlockAt(root: MmBlock[], fromPath: BlockPath, toIndex: number): MmBlock[] {
const ctx = getParentContext(root, fromPath);
if (!ctx || toIndex < 0 || toIndex >= ctx.list.length) {
return root;
}
const fromIndex = ctx.index;
if (fromIndex === toIndex) {
return root;
}
const draft = cloneBlocks(root);
const draftCtx = getParentContext(draft, fromPath);
if (!draftCtx) {
return draft;
}
const [item] = draftCtx.list.splice(draftCtx.index, 1);
draftCtx.list.splice(toIndex, 0, item);
return draft;
}
/** Updates a path after a sibling reorder in the same list. */
export function remapPathAfterMove(path: BlockPath, fromPath: BlockPath, toIndex: number): BlockPath {
if (!sameParentList(path, fromPath)) {
return path;
}
const fromIndex = fromPath[fromPath.length - 1].index;
const last = path[path.length - 1];
if (pathKey(path) === pathKey(fromPath)) {
return [...path.slice(0, -1), {list: last.list, index: toIndex}];
}
const pathIndex = last.index;
if (fromIndex < pathIndex && pathIndex <= toIndex) {
return [...path.slice(0, -1), {list: last.list, index: pathIndex - 1}];
}
if (toIndex <= pathIndex && pathIndex < fromIndex) {
return [...path.slice(0, -1), {list: last.list, index: pathIndex + 1}];
}
return path;
}
export const MM_BLOCKS_DRAG_MIME = 'application/x-mm-block-path';
export function listLabel(list: ChildListKey): string | null {
switch (list) {
case 'content':
return 'content';
case 'items':
return 'items';
case 'columns':
return 'columns';
case 'header':
return 'header';
default:
return null;
}
}
export function childPaths(block: MmBlock | MmColumnBlock, parentPath: BlockPath): BlockPath[] {
const paths: BlockPath[] = [];
const appendChildren = (list: ChildListKey, items: Array<MmBlock | MmColumnBlock>) => {
items.forEach((_, index) => {
paths.push([...parentPath, {list, index}]);
});
};
switch (block.type) {
case 'container':
appendChildren('content', block.content);
break;
case 'column':
appendChildren('items', block.items);
break;
case 'column_set':
appendChildren('columns', block.columns);
break;
case 'collapsible':
appendChildren('header', block.header);
appendChildren('content', block.content);
break;
default:
break;
}
return paths;
}
export function blockSummary(block: MmBlock | MmColumnBlock): string {
switch (block.type) {
case 'text': {
const preview = block.text.replace(/\s+/g, ' ').trim();
const truncated = preview.length > 40 ? `${preview.slice(0, 40)}` : preview;
return truncated || '(empty text)';
}
case 'button':
return block.text || block.action_id;
case 'static_select':
return block.placeholder || block.action_id;
case 'image':
return block.alt_text || block.url;
case 'container':
return `${block.content.length} item${block.content.length === 1 ? '' : 's'}`;
case 'column':
return `${block.items.length} item${block.items.length === 1 ? '' : 's'}`;
case 'column_set':
return `${block.columns.length} column${block.columns.length === 1 ? '' : 's'}`;
case 'collapsible':
return 'Collapsible section';
case 'divider':
return 'Horizontal rule';
default:
return 'Unknown block';
}
}
const TEXT_SIZE_OPTIONS: MmTextSize[] = ['small', 'default'];
const BUTTON_STYLE_OPTIONS: MmButtonStyle[] = ['default', 'primary', 'danger', 'good', 'success', 'warning'];
const IMAGE_SIZE_OPTIONS: MmImageSize[] = ['auto', 'xsmall', 'small', 'medium', 'large', 'stretch'];
const CONTAINER_GAP_OPTIONS: MmContainerGap[] = ['none', 'small', 'medium', 'large', 'xlarge'];
const CONTAINER_BACKGROUND_OPTIONS: MmContainerBackground[] = ['none', 'gray'];
const CONTAINER_MAX_HEIGHT_OPTIONS: MmContainerMaxHeight[] = ['none', 'small', 'medium', 'large'];
export function propertyFieldsForBlock(block: MmBlock | MmColumnBlock): PropertyField[] {
switch (block.type) {
case 'text':
return [
{key: 'text', label: 'text', type: 'string', placeholder: 'Markdown text'},
{key: 'is_subtle', label: 'is_subtle', type: 'boolean'},
{key: 'size', label: 'size', type: 'enum', options: TEXT_SIZE_OPTIONS},
];
case 'divider':
return [];
case 'button':
return [
{key: 'text', label: 'text', type: 'string'},
{key: 'action_id', label: 'action_id', type: 'string'},
{key: 'style', label: 'style', type: 'enum', options: BUTTON_STYLE_OPTIONS},
{key: 'tooltip', label: 'tooltip', type: 'string'},
{key: 'disabled', label: 'disabled', type: 'boolean'},
{key: 'query', label: 'query', type: 'json'},
{key: 'cookie', label: 'cookie', type: 'string'},
];
case 'static_select':
return [
{key: 'action_id', label: 'action_id', type: 'string'},
{key: 'placeholder', label: 'placeholder', type: 'string'},
{key: 'options', label: 'options', type: 'json'},
{key: 'initial_option', label: 'initial_option', type: 'string'},
{key: 'disabled', label: 'disabled', type: 'boolean'},
{key: 'data_source', label: 'data_source', type: 'string'},
{key: 'query', label: 'query', type: 'json'},
{key: 'cookie', label: 'cookie', type: 'string'},
];
case 'image':
return [
{key: 'url', label: 'url', type: 'string'},
{key: 'alt_text', label: 'alt_text', type: 'string'},
{key: 'title', label: 'title', type: 'string'},
{key: 'size', label: 'size', type: 'enum', options: IMAGE_SIZE_OPTIONS},
{key: 'max_width', label: 'max_width', type: 'number'},
{key: 'max_height', label: 'max_height', type: 'number'},
{key: 'image_style', label: 'image_style', type: 'enum', options: ['default', 'person']},
{key: 'horizontal_alignment', label: 'horizontal_alignment', type: 'enum', options: ['left', 'center', 'right']},
];
case 'column':
return [
{key: 'width', label: 'width', type: 'enum', options: ['auto', 'stretch']},
];
case 'container':
return [
{key: 'border', label: 'border', type: 'boolean'},
{key: 'accent_color', label: 'accent_color', type: 'string', placeholder: 'Semantic or #hex'},
{key: 'flow', label: 'flow', type: 'enum', options: ['horizontal', 'vertical']},
{key: 'gap', label: 'gap', type: 'enum', options: CONTAINER_GAP_OPTIONS},
{key: 'background', label: 'background', type: 'enum', options: CONTAINER_BACKGROUND_OPTIONS},
{key: 'max_height', label: 'max_height', type: 'enum', options: CONTAINER_MAX_HEIGHT_OPTIONS},
];
case 'collapsible':
return [
{key: 'collapsed', label: 'collapsed', type: 'boolean'},
];
case 'column_set':
return [];
default:
return [];
}
}
export function getPropertyValue(block: MmBlock | MmColumnBlock, key: string): unknown {
return (block as Record<string, unknown>)[key];
}
export function setPropertyValue(
block: MmBlock | MmColumnBlock,
key: string,
raw: string,
field: PropertyField,
): MmBlock | MmColumnBlock {
const next = {...block} as Record<string, unknown>;
if (field.type === 'boolean') {
if (raw === '' || raw === 'false') {
delete next[key];
} else {
next[key] = true;
}
return next as MmBlock | MmColumnBlock;
}
if (field.type === 'number') {
if (raw.trim() === '') {
delete next[key];
} else {
const num = Number(raw);
if (Number.isFinite(num)) {
next[key] = num;
}
}
return next as MmBlock | MmColumnBlock;
}
if (field.type === 'enum') {
if (raw === '') {
delete next[key];
} else {
next[key] = raw;
}
return next as MmBlock | MmColumnBlock;
}
if (field.type === 'json') {
if (raw.trim() === '') {
delete next[key];
return next as MmBlock | MmColumnBlock;
}
try {
const parsed = JSON.parse(raw);
if (key === 'options' && Array.isArray(parsed)) {
next[key] = parsed as MmStaticSelectOption[];
} else if (key === 'query' && typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
next[key] = parsed as Record<string, string>;
} else {
next[key] = parsed;
}
} catch {
return block;
}
return next as MmBlock | MmColumnBlock;
}
if (raw.trim() === '') {
delete next[key];
} else {
next[key] = raw;
}
return next as MmBlock | MmColumnBlock;
}
export function formatPropertyValue(value: unknown, field: PropertyField): string {
if (value === undefined || value === null) {
return '';
}
if (field.type === 'boolean') {
return value === true ? 'true' : '';
}
if (field.type === 'json') {
return JSON.stringify(value, null, 2);
}
if (field.type === 'number') {
return String(value);
}
return String(value);
}

View file

@ -0,0 +1,262 @@
.MmBlocksHierarchyEditor {
display: flex;
flex-wrap: wrap;
gap: 16px;
max-width: 56rem;
margin-bottom: 16px;
}
.MmBlocksHierarchyEditor__treePanel,
.MmBlocksHierarchyEditor__propertiesPanel {
flex: 1 1 240px;
min-width: 220px;
padding: 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: rgba(var(--center-channel-color-rgb), 0.04);
}
.MmBlocksHierarchyEditor__heading {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--center-channel-color);
}
.MmBlocksHierarchyEditor__empty {
margin: 0;
font-size: 12px;
color: rgba(var(--center-channel-color-rgb), 0.64);
}
.MmBlocksHierarchyEditor__emptyRoot {
display: flex;
flex-direction: column;
gap: 8px;
}
.MmBlocksHierarchyEditor__emptyAdd {
position: relative;
align-self: flex-start;
}
.MmBlocksHierarchyEditor__tree,
.MmBlocksHierarchyEditor__children {
margin: 0;
padding: 0;
list-style: none;
}
.MmBlocksHierarchyEditor__listLabel {
margin: 6px 0 2px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(var(--center-channel-color-rgb), 0.48);
list-style: none;
}
.MmBlocksHierarchyEditor__node {
list-style: none;
}
.MmBlocksHierarchyEditor__row {
position: relative;
display: flex;
align-items: center;
gap: 4px;
min-height: 28px;
border-radius: 3px;
&:hover,
&:focus-within {
background: rgba(var(--button-bg-rgb), 0.08);
.MmBlocksHierarchyEditor__rowActions,
.MmBlocksHierarchyEditor__dragHandle {
opacity: 1;
}
.MmBlocksHierarchyEditor__rowActions {
pointer-events: auto;
}
}
&.is-selected {
background: rgba(var(--button-bg-rgb), 0.14);
}
&.is-dragging {
opacity: 0.45;
}
&.is-drag-over {
background: rgba(var(--button-bg-rgb), 0.18);
outline: 1px dashed var(--button-bg);
outline-offset: -1px;
}
}
.MmBlocksHierarchyEditor__dragHandle {
flex-shrink: 0;
padding: 2px 4px;
border: none;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.44);
font-size: 12px;
line-height: 1;
cursor: grab;
opacity: 0;
user-select: none;
&:active {
cursor: grabbing;
}
&:focus-visible {
opacity: 1;
outline: 2px solid var(--button-bg);
outline-offset: 1px;
}
}
.MmBlocksHierarchyEditor__rowButton {
flex: 1;
min-width: 0;
padding: 4px 6px;
border: none;
background: transparent;
color: var(--center-channel-color);
font-size: 12px;
text-align: left;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--button-bg);
outline-offset: 1px;
}
}
.MmBlocksHierarchyEditor__type {
display: inline-block;
min-width: 5.5rem;
margin-right: 6px;
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
font-size: 11px;
color: var(--link-color);
}
.MmBlocksHierarchyEditor__summary {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
.MmBlocksHierarchyEditor__rowActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
padding-right: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
}
.MmBlocksHierarchyEditor__addMenu {
position: absolute;
z-index: 5;
top: 100%;
right: 0;
min-width: 10rem;
padding: 4px 0;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
border-radius: 4px;
margin-top: 2px;
background: var(--center-channel-bg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.MmBlocksHierarchyEditor__addMenuHeading {
padding: 6px 12px 2px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(var(--center-channel-color-rgb), 0.48);
}
.MmBlocksHierarchyEditor__addMenuItem {
display: block;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
color: var(--center-channel-color);
font-size: 12px;
text-align: left;
cursor: pointer;
&:hover {
background: rgba(var(--button-bg-rgb), 0.1);
}
}
.MmBlocksHierarchyEditor__properties {
display: flex;
flex-direction: column;
gap: 10px;
}
.MmBlocksHierarchyEditor__propertyRow {
display: grid;
grid-template-columns: 7rem 1fr;
gap: 8px;
align-items: center;
font-size: 12px;
&--stacked {
grid-template-columns: 1fr;
}
input,
select,
textarea {
width: 100%;
box-sizing: border-box;
padding: 4px 6px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.24);
border-radius: 3px;
background: var(--center-channel-bg);
color: var(--center-channel-color);
font-size: 12px;
}
}
.MmBlocksHierarchyEditor__propertyLabel {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
font-size: 11px;
color: rgba(var(--center-channel-color-rgb), 0.8);
}
.MmBlocksHierarchyEditor__propertyJson {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
line-height: 1.4;
resize: vertical;
}
.clMmBlocksEditorLayout {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
max-width: 72rem;
}
.clMmBlocksEditorLayout__json {
flex: 1 1 280px;
min-width: 260px;
}
.clMmBlocksEditorLayout__json .clJsonEditor {
max-width: none;
}

View file

@ -0,0 +1,650 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable formatjs/no-literal-string-in-jsx -- component library dev playground */
import classNames from 'classnames';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Button} from '@mattermost/shared/components/button';
import type {MmBlock, MmColumnBlock} from '@mattermost/types/mm_blocks';
import {
type AddBlockTarget,
type BlockPath,
type BlockTypeId,
type ChildListKey,
MM_BLOCKS_DRAG_MIME,
ROOT_ADDABLE_TYPES,
addableTypesForList,
blockSummary,
blockTypeLabel,
canAddChild,
childPaths,
createDefaultBlock,
formatPropertyValue,
getBlockAt,
getPropertyValue,
insertBlockAt,
listLabel,
moveBlockAt,
parsePathKey,
pathKey,
propertyFieldsForBlock,
remapPathAfterMove,
removeBlockAt,
sameParentList,
setPropertyValue,
type PropertyField,
updateBlockAt,
} from './mm_blocks_editor_utils';
import './mm_blocks_hierarchy_editor.scss';
type Props = {
blocks: MmBlock[];
selectedPath: BlockPath | null;
onSelectPath: (path: BlockPath | null) => void;
onChangeBlocks: (blocks: MmBlock[]) => void;
};
type AddMenuState = {
path: BlockPath;
};
const PropertyFieldEditor = ({
block,
field,
onChange,
}: {
block: MmBlock | MmColumnBlock;
field: PropertyField;
onChange: (next: MmBlock | MmColumnBlock) => void;
}) => {
const value = getPropertyValue(block, field.key);
const display = formatPropertyValue(value, field);
const id = `mm-block-prop-${field.key}`;
const onStringChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onChange(setPropertyValue(block, field.key, e.target.value, field));
}, [block, field, onChange]);
const onBooleanChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(setPropertyValue(block, field.key, e.target.checked ? 'true' : '', field));
}, [block, field, onChange]);
const onEnumChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(setPropertyValue(block, field.key, e.target.value, field));
}, [block, field, onChange]);
if (field.type === 'boolean') {
return (
<label className='MmBlocksHierarchyEditor__propertyRow'>
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
<input
id={id}
type='checkbox'
checked={value === true}
onChange={onBooleanChange}
/>
</label>
);
}
if (field.type === 'enum' && field.options) {
return (
<label className='MmBlocksHierarchyEditor__propertyRow'>
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
<select
id={id}
value={display}
onChange={onEnumChange}
>
<option value=''>{'(unset)'}</option>
{field.options.map((opt) => (
<option
key={opt}
value={opt}
>
{opt}
</option>
))}
</select>
</label>
);
}
if (field.type === 'json') {
return (
<label className='MmBlocksHierarchyEditor__propertyRow MmBlocksHierarchyEditor__propertyRow--stacked'>
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
<textarea
id={id}
className='MmBlocksHierarchyEditor__propertyJson'
spellCheck={false}
rows={4}
value={display}
placeholder={field.placeholder ?? 'JSON'}
onChange={onStringChange}
/>
</label>
);
}
const isLong = field.key === 'text';
return (
<label
className={classNames('MmBlocksHierarchyEditor__propertyRow', {
'MmBlocksHierarchyEditor__propertyRow--stacked': isLong,
})}
>
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
{isLong ? (
<textarea
id={id}
className='MmBlocksHierarchyEditor__propertyJson'
spellCheck={false}
rows={3}
value={display}
placeholder={field.placeholder}
onChange={onStringChange}
/>
) : (
<input
id={id}
type={field.type === 'number' ? 'number' : 'text'}
value={display}
placeholder={field.placeholder}
onChange={onStringChange}
/>
)}
</label>
);
};
const AddBlockMenu = ({
addableTypes,
childAddableTypes,
onPick,
onPickChild,
onClose,
}: {
addableTypes: BlockTypeId[];
childAddableTypes?: BlockTypeId[];
onPick: (type: BlockTypeId) => void;
onPickChild?: (type: BlockTypeId) => void;
onClose: () => void;
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [onClose]);
return (
<div
ref={ref}
className='MmBlocksHierarchyEditor__addMenu'
role='menu'
>
{childAddableTypes && onPickChild && childAddableTypes.length > 0 && (
<>
<div className='MmBlocksHierarchyEditor__addMenuHeading'>{'Inside block'}</div>
{childAddableTypes.map((type) => (
<button
key={`child-${type}`}
type='button'
className='MmBlocksHierarchyEditor__addMenuItem'
role='menuitem'
onClick={() => onPickChild(type)}
>
{blockTypeLabel(type)}
</button>
))}
<div className='MmBlocksHierarchyEditor__addMenuHeading'>{'After block'}</div>
</>
)}
{addableTypes.map((type) => (
<button
key={type}
type='button'
className='MmBlocksHierarchyEditor__addMenuItem'
role='menuitem'
onClick={() => onPick(type)}
>
{blockTypeLabel(type)}
</button>
))}
</div>
);
};
type HierarchyNodeProps = {
root: MmBlock[];
block: MmBlock | MmColumnBlock;
path: BlockPath;
depth: number;
selectedPath: BlockPath | null;
draggingPath: BlockPath | null;
dragOverPath: BlockPath | null;
addMenu: AddMenuState | null;
onSelectPath: (path: BlockPath) => void;
onOpenAddMenu: (state: AddMenuState) => void;
onCloseAddMenu: () => void;
onAddBlock: (path: BlockPath, type: BlockTypeId, target: AddBlockTarget) => void;
onRemoveBlock: (path: BlockPath) => void;
onDragStart: (path: BlockPath) => void;
onDragOver: (path: BlockPath) => void;
onDragEnd: () => void;
onDrop: (fromPath: BlockPath, toIndex: number) => void;
};
const HierarchyNode = ({
root,
block,
path,
depth,
selectedPath,
draggingPath,
dragOverPath,
addMenu,
onSelectPath,
onOpenAddMenu,
onCloseAddMenu,
onAddBlock,
onRemoveBlock,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
}: HierarchyNodeProps) => {
const key = pathKey(path);
const isSelected = selectedPath !== null && pathKey(selectedPath) === key;
const isDragging = draggingPath !== null && pathKey(draggingPath) === key;
const isDragOver = dragOverPath !== null && pathKey(dragOverPath) === key && !isDragging;
const canDrop = draggingPath !== null && sameParentList(draggingPath, path);
const parentList = path[path.length - 1]?.list ?? 'root';
const supportsChild = canAddChild(block);
const menuOpen = addMenu !== null && pathKey(addMenu.path) === key;
const childListKey = useMemo((): ChildListKey | null => {
switch (block.type) {
case 'container':
return 'content';
case 'column':
return 'items';
case 'column_set':
return 'columns';
case 'collapsible':
return 'content';
default:
return null;
}
}, [block.type]);
const siblingTypes = addableTypesForList(parentList);
const childTypes = supportsChild && childListKey ? addableTypesForList(childListKey) : [];
const onSelect = useCallback(() => {
onSelectPath(path);
}, [onSelectPath, path]);
const onRemove = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onRemoveBlock(path);
}, [onRemoveBlock, path]);
const onAdd = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onOpenAddMenu({path});
}, [onOpenAddMenu, path]);
const onHandleDragStart = useCallback((e: React.DragEvent) => {
e.stopPropagation();
e.dataTransfer.setData(MM_BLOCKS_DRAG_MIME, key);
e.dataTransfer.effectAllowed = 'move';
onDragStart(path);
}, [key, onDragStart, path]);
const onRowDragOver = useCallback((e: React.DragEvent) => {
if (!draggingPath || !sameParentList(draggingPath, path)) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
onDragOver(path);
}, [draggingPath, onDragOver, path]);
const onRowDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const fromKey = e.dataTransfer.getData(MM_BLOCKS_DRAG_MIME);
const fromPath = parsePathKey(fromKey);
if (!fromPath || !sameParentList(fromPath, path)) {
onDragEnd();
return;
}
onDrop(fromPath, path[path.length - 1].index);
onDragEnd();
}, [onDragEnd, onDrop, path]);
const childPathList = useMemo(() => childPaths(block, path), [block, path]);
const childGroups = useMemo(() => {
const groups: Array<{list: ChildListKey; label: string | null; paths: BlockPath[]}> = [];
for (const childPath of childPathList) {
const segment = childPath[childPath.length - 1];
const last = groups[groups.length - 1];
if (last && last.list === segment.list) {
last.paths.push(childPath);
} else {
groups.push({
list: segment.list,
label: listLabel(segment.list),
paths: [childPath],
});
}
}
return groups;
}, [childPathList]);
return (
<li className='MmBlocksHierarchyEditor__node'>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- drop target for reorder */}
<div
className={classNames('MmBlocksHierarchyEditor__row', {
'is-selected': isSelected,
'is-dragging': isDragging,
'is-drag-over': isDragOver,
'can-drop': canDrop,
})}
style={{paddingLeft: `${(depth * 12) + 8}px`}}
onDragOver={onRowDragOver}
onDrop={onRowDrop}
>
<button
type='button'
className='MmBlocksHierarchyEditor__dragHandle'
draggable={true}
onDragStart={onHandleDragStart}
onDragEnd={onDragEnd}
onClick={(e) => e.preventDefault()}
aria-label='Drag to reorder'
title='Drag to reorder'
>
{'⠿'}
</button>
<button
type='button'
className='MmBlocksHierarchyEditor__rowButton'
onClick={onSelect}
aria-pressed={isSelected}
>
<span className='MmBlocksHierarchyEditor__type'>{block.type}</span>
<span className='MmBlocksHierarchyEditor__summary'>{blockSummary(block)}</span>
</button>
<div className='MmBlocksHierarchyEditor__rowActions'>
<Button
size='xs'
emphasis='quaternary'
onClick={onAdd}
aria-label='Add block'
>
{'+'}
</Button>
<Button
size='xs'
emphasis='quaternary'
onClick={onRemove}
aria-label='Remove block'
>
{'×'}
</Button>
</div>
{menuOpen && addMenu && pathKey(addMenu.path) === key && (
<AddBlockMenu
addableTypes={siblingTypes}
childAddableTypes={childTypes}
onPick={(type) => {
onAddBlock(addMenu.path, type, 'sibling');
onCloseAddMenu();
}}
onPickChild={childTypes.length > 0 ? (type) => {
onAddBlock(addMenu.path, type, 'child');
onCloseAddMenu();
} : undefined}
onClose={onCloseAddMenu}
/>
)}
</div>
{childGroups.length > 0 && (
<ul className='MmBlocksHierarchyEditor__children'>
{childGroups.map((group) => (
<React.Fragment key={`${key}/${group.list}`}>
{group.label && (
<li
className='MmBlocksHierarchyEditor__listLabel'
style={{paddingLeft: `${((depth + 1) * 12) + 8}px`}}
>
{group.label}
</li>
)}
{group.paths.map((childPath) => {
const childBlock = getBlockAt(root, childPath);
if (!childBlock) {
return null;
}
return (
<HierarchyNode
key={pathKey(childPath)}
root={root}
block={childBlock}
path={childPath}
depth={depth + 1}
selectedPath={selectedPath}
draggingPath={draggingPath}
dragOverPath={dragOverPath}
addMenu={addMenu}
onSelectPath={onSelectPath}
onOpenAddMenu={onOpenAddMenu}
onCloseAddMenu={onCloseAddMenu}
onAddBlock={onAddBlock}
onRemoveBlock={onRemoveBlock}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDrop={onDrop}
/>
);
})}
</React.Fragment>
))}
</ul>
)}
</li>
);
};
const MmBlocksHierarchyEditor = ({
blocks,
selectedPath,
onSelectPath,
onChangeBlocks,
}: Props) => {
const [addMenu, setAddMenu] = useState<AddMenuState | null>(null);
const [rootAddOpen, setRootAddOpen] = useState(false);
const [draggingPath, setDraggingPath] = useState<BlockPath | null>(null);
const [dragOverPath, setDragOverPath] = useState<BlockPath | null>(null);
const selectedBlock = useMemo(() => {
if (!selectedPath) {
return null;
}
return getBlockAt(blocks, selectedPath);
}, [blocks, selectedPath]);
const propertyFields = useMemo(() => {
if (!selectedBlock) {
return [];
}
return propertyFieldsForBlock(selectedBlock);
}, [selectedBlock]);
const onCloseAddMenu = useCallback(() => {
setAddMenu(null);
}, []);
const onAddBlock = useCallback((path: BlockPath, type: BlockTypeId, target: AddBlockTarget) => {
const newBlock = createDefaultBlock(type);
const next = insertBlockAt(blocks, path, newBlock, target);
onChangeBlocks(next);
if (target === 'child') {
const parent = getBlockAt(next, path);
if (!parent || !canAddChild(parent)) {
return;
}
let childList: ChildListKey = 'content';
let listLength = 0;
if (parent.type === 'container') {
childList = 'content';
listLength = parent.content.length;
} else if (parent.type === 'column') {
childList = 'items';
listLength = parent.items.length;
} else if (parent.type === 'column_set') {
childList = 'columns';
listLength = parent.columns.length;
} else if (parent.type === 'collapsible') {
childList = 'content';
listLength = parent.content.length;
}
onSelectPath([...path, {list: childList, index: listLength - 1}]);
}
}, [blocks, onChangeBlocks, onSelectPath]);
const onRemoveBlock = useCallback((path: BlockPath) => {
const next = removeBlockAt(blocks, path);
onChangeBlocks(next);
if (selectedPath && pathKey(selectedPath) === pathKey(path)) {
onSelectPath(null);
}
}, [blocks, onChangeBlocks, onSelectPath, selectedPath]);
const onDragEnd = useCallback(() => {
setDraggingPath(null);
setDragOverPath(null);
}, []);
const onDrop = useCallback((fromPath: BlockPath, toIndex: number) => {
const next = moveBlockAt(blocks, fromPath, toIndex);
onChangeBlocks(next);
if (selectedPath) {
onSelectPath(remapPathAfterMove(selectedPath, fromPath, toIndex));
}
}, [blocks, onChangeBlocks, onSelectPath, selectedPath]);
const onPropertyChange = useCallback((next: MmBlock | MmColumnBlock) => {
if (!selectedPath) {
return;
}
onChangeBlocks(updateBlockAt(blocks, selectedPath, next));
}, [blocks, onChangeBlocks, selectedPath]);
const rootPaths = useMemo(
() => blocks.map((_, index): BlockPath => [{list: 'root', index}]),
[blocks],
);
return (
<div className='MmBlocksHierarchyEditor'>
<div className='MmBlocksHierarchyEditor__treePanel'>
<h3 className='MmBlocksHierarchyEditor__heading'>{'Block hierarchy'}</h3>
{blocks.length === 0 ? (
<div className='MmBlocksHierarchyEditor__emptyRoot'>
<p className='MmBlocksHierarchyEditor__empty'>{'No blocks yet.'}</p>
<div className='MmBlocksHierarchyEditor__emptyAdd'>
<Button
size='xs'
onClick={() => setRootAddOpen((open) => !open)}
>
{'Add block'}
</Button>
{rootAddOpen && (
<AddBlockMenu
addableTypes={ROOT_ADDABLE_TYPES}
onPick={(type) => {
onChangeBlocks([createDefaultBlock(type)]);
setRootAddOpen(false);
}}
onClose={() => setRootAddOpen(false)}
/>
)}
</div>
</div>
) : (
<ul className='MmBlocksHierarchyEditor__tree'>
{rootPaths.map((path, i) => {
const block = blocks[i];
if (!block) {
return null;
}
return (
<HierarchyNode
key={pathKey(path)}
root={blocks}
block={block}
path={path}
depth={0}
selectedPath={selectedPath}
draggingPath={draggingPath}
dragOverPath={dragOverPath}
addMenu={addMenu}
onSelectPath={onSelectPath}
onOpenAddMenu={setAddMenu}
onCloseAddMenu={onCloseAddMenu}
onAddBlock={onAddBlock}
onRemoveBlock={onRemoveBlock}
onDragStart={setDraggingPath}
onDragOver={setDragOverPath}
onDragEnd={onDragEnd}
onDrop={onDrop}
/>
);
})}
</ul>
)}
</div>
<div className='MmBlocksHierarchyEditor__propertiesPanel'>
<h3 className='MmBlocksHierarchyEditor__heading'>{'Properties'}</h3>
{!selectedBlock && (
<p className='MmBlocksHierarchyEditor__empty'>{'Select a block to edit its properties.'}</p>
)}
{selectedBlock && propertyFields.length === 0 && (
<p className='MmBlocksHierarchyEditor__empty'>
{`Block type "${selectedBlock.type}" has no editable scalar properties. Edit nested blocks in the hierarchy.`}
</p>
)}
{selectedBlock && propertyFields.length > 0 && (
<div className='MmBlocksHierarchyEditor__properties'>
{propertyFields.map((field) => (
<PropertyFieldEditor
key={field.key}
block={selectedBlock}
field={field}
onChange={onPropertyChange}
/>
))}
</div>
)}
</div>
</div>
);
};
export default MmBlocksHierarchyEditor;