Skip to content

Commit ac65a91

Browse files
committed
feat: support grouped grid templates
1 parent 868e5e1 commit ac65a91

15 files changed

Lines changed: 555 additions & 85 deletions

File tree

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/BlockInsertPopup.tsx

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useMemo, useState} from 'react';
1+
import {useState} from 'react';
22

33
import {Code} from '@gravity-ui/icons';
44
import {Button, Icon, Menu, Popup, TextInput} from '@gravity-ui/uikit';
@@ -9,6 +9,7 @@ import {i18n} from 'src/i18n/grid-block-templates';
99
import {parseRawBlock} from '../templates';
1010
import type {GridBlockBlockTemplate, GridBlockTemplateBlock} from '../types';
1111

12+
import {GroupedTemplatesMenuItems} from './GroupedTemplatesMenuItems';
1213
import {STOP_EVENT_CLASSNAME, cnGridBlockTemplates} from './const';
1314

1415
const b = cnGridBlockTemplates;
@@ -36,12 +37,6 @@ export const BlockInsertPopup: React.FC<BlockInsertPopupProps> = ({
3637
const [filter, setFilter] = useState('');
3738
const showCustomHtmlEditor = addingCustomHtml || templates.length === 0;
3839

39-
const filtered = useMemo(() => {
40-
const query = filter.trim().toLowerCase();
41-
if (!query) return templates;
42-
return templates.filter((template) => template.title.toLowerCase().includes(query));
43-
}, [templates, filter]);
44-
4540
const close = () => {
4641
setAddingCustomHtml(false);
4742
setInput('');
@@ -107,23 +102,15 @@ export const BlockInsertPopup: React.FC<BlockInsertPopupProps> = ({
107102
role="separator"
108103
className={b('templates-separator', [stop])}
109104
/>
110-
{filtered.map((template) => (
111-
<Menu.Item
112-
key={template.id}
113-
className={stop}
114-
onClick={() => {
115-
onApplyTemplate(template);
116-
close();
117-
}}
118-
>
119-
{template.title}
120-
</Menu.Item>
121-
))}
122-
{filtered.length === 0 && (
123-
<Menu.Item disabled className={stop}>
124-
{i18n('block_templates_empty')}
125-
</Menu.Item>
126-
)}
105+
<GroupedTemplatesMenuItems
106+
templates={templates}
107+
filter={filter}
108+
emptyText={i18n('block_templates_empty')}
109+
onApply={(template) => {
110+
onApplyTemplate(template);
111+
close();
112+
}}
113+
/>
127114
</Menu>
128115
</div>
129116
</>
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {useEffect, useRef, useState} from 'react';
2+
3+
import {ChevronRight} from '@gravity-ui/icons';
4+
import {Icon, Menu, Popup} from '@gravity-ui/uikit';
5+
6+
import {useBooleanState, useElementState} from 'src/react-utils/hooks';
7+
8+
import type {GridBlockTemplate} from '../types';
9+
10+
import {STOP_EVENT_CLASSNAME, cnGridBlockTemplates} from './const';
11+
import {groupTemplatesForMenu} from './groupTemplates';
12+
13+
const CLOSE_DELAY = 120;
14+
const SUBMENU_WIDTH = 320;
15+
16+
const b = cnGridBlockTemplates;
17+
const stop = STOP_EVENT_CLASSNAME;
18+
19+
type SubmenuPlacement = 'right-start' | 'left-start';
20+
21+
interface GroupedTemplatesMenuItemsProps<TTemplate extends GridBlockTemplate> {
22+
templates: TTemplate[];
23+
filter: string;
24+
emptyText: string;
25+
onApply: (template: TTemplate) => void;
26+
}
27+
28+
interface TemplateGroupMenuItemProps<TTemplate extends GridBlockTemplate> {
29+
title: string;
30+
templates: TTemplate[];
31+
onApply: (template: TTemplate) => void;
32+
}
33+
34+
function TemplateGroupMenuItem<TTemplate extends GridBlockTemplate>({
35+
title,
36+
templates,
37+
onApply,
38+
}: TemplateGroupMenuItemProps<TTemplate>) {
39+
const [anchorElement, setAnchorElement] = useElementState<HTMLDivElement>();
40+
const [open, openPopup, closePopup] = useBooleanState(false);
41+
const [placement, setPlacement] = useState<SubmenuPlacement>('right-start');
42+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
43+
44+
const cancelClose = () => {
45+
if (closeTimer.current) {
46+
clearTimeout(closeTimer.current);
47+
closeTimer.current = null;
48+
}
49+
};
50+
51+
const scheduleClose = () => {
52+
cancelClose();
53+
closeTimer.current = setTimeout(closePopup, CLOSE_DELAY);
54+
};
55+
56+
const openSubmenu = () => {
57+
cancelClose();
58+
59+
const rect = anchorElement?.getBoundingClientRect();
60+
const hasRightSpace =
61+
!rect ||
62+
typeof window === 'undefined' ||
63+
rect.right + SUBMENU_WIDTH <= window.innerWidth;
64+
setPlacement(hasRightSpace ? 'right-start' : 'left-start');
65+
openPopup();
66+
};
67+
68+
useEffect(() => () => cancelClose(), []);
69+
70+
return (
71+
<Menu.Item
72+
ref={setAnchorElement}
73+
className={stop}
74+
iconEnd={<Icon data={ChevronRight} />}
75+
onClick={openSubmenu}
76+
extraProps={{
77+
onFocus: openSubmenu,
78+
onMouseEnter: openSubmenu,
79+
onMouseLeave: scheduleClose,
80+
}}
81+
>
82+
{title}
83+
<Popup open={open} hasArrow={false} placement={placement} anchorElement={anchorElement}>
84+
<div
85+
className={b('templates-submenu', [stop])}
86+
onMouseEnter={cancelClose}
87+
onMouseLeave={scheduleClose}
88+
>
89+
<Menu className={stop}>
90+
{templates.map((template) => (
91+
<Menu.Item
92+
key={template.id}
93+
className={stop}
94+
onClick={() => {
95+
onApply(template);
96+
closePopup();
97+
}}
98+
>
99+
{template.title}
100+
</Menu.Item>
101+
))}
102+
</Menu>
103+
</div>
104+
</Popup>
105+
</Menu.Item>
106+
);
107+
}
108+
109+
export function GroupedTemplatesMenuItems<TTemplate extends GridBlockTemplate>({
110+
templates,
111+
filter,
112+
emptyText,
113+
onApply,
114+
}: GroupedTemplatesMenuItemsProps<TTemplate>) {
115+
const entries = groupTemplatesForMenu(templates, filter);
116+
117+
if (entries.length === 0) {
118+
return (
119+
<Menu.Item disabled className={stop}>
120+
{emptyText}
121+
</Menu.Item>
122+
);
123+
}
124+
125+
return (
126+
<>
127+
{entries.map((entry) =>
128+
entry.type === 'group' ? (
129+
<TemplateGroupMenuItem
130+
key={`group:${entry.title}`}
131+
title={entry.title}
132+
templates={entry.templates}
133+
onApply={onApply}
134+
/>
135+
) : (
136+
<Menu.Item
137+
key={entry.template.id}
138+
className={stop}
139+
onClick={() => onApply(entry.template)}
140+
>
141+
{entry.template.title}
142+
</Menu.Item>
143+
),
144+
)}
145+
</>
146+
);
147+
}

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/TemplatesPopup.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@
3131
max-height: 280px;
3232
}
3333

34+
&__templates-submenu {
35+
overflow-y: auto;
36+
37+
width: 320px;
38+
max-width: calc(100vw - 32px);
39+
max-height: 280px;
40+
padding: 8px;
41+
42+
border-radius: var(--g-border-radius-m);
43+
background: var(--g-color-base-float);
44+
box-shadow: 0 2px 10px var(--g-color-sfx-shadow);
45+
}
46+
3447
&__templates-separator {
3548
height: 1px;
3649
margin: 4px 0;

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/TemplatesPopup.tsx

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useMemo, useState} from 'react';
1+
import {useState} from 'react';
22

33
import {Plus, TrashBin} from '@gravity-ui/icons';
44
import {Button, Icon, Menu, Popup, TextInput} from '@gravity-ui/uikit';
@@ -9,6 +9,7 @@ import {i18n} from 'src/i18n/grid-block-templates';
99
import {clearStoredTemplates, parseTemplates, saveTemplates} from '../templates';
1010
import type {GridBlockTemplate} from '../types';
1111

12+
import {GroupedTemplatesMenuItems} from './GroupedTemplatesMenuItems';
1213
import {STOP_EVENT_CLASSNAME, cnGridBlockTemplates} from './const';
1314

1415
import './TemplatesPopup.scss';
@@ -43,12 +44,6 @@ export function TemplatesPopup<TTemplate extends GridBlockTemplate>({
4344
const [input, setInput] = useState('');
4445
const [filter, setFilter] = useState('');
4546

46-
const filtered = useMemo(() => {
47-
const query = filter.trim().toLowerCase();
48-
if (!query) return templates;
49-
return templates.filter((template) => template.title.toLowerCase().includes(query));
50-
}, [templates, filter]);
51-
5247
const close = () => {
5348
setAdding(false);
5449
setInput('');
@@ -134,23 +129,15 @@ export function TemplatesPopup<TTemplate extends GridBlockTemplate>({
134129
/>
135130
</>
136131
)}
137-
{filtered.map((template) => (
138-
<Menu.Item
139-
key={template.id}
140-
className={stop}
141-
onClick={() => {
142-
onApply(template);
143-
close();
144-
}}
145-
>
146-
{template.title}
147-
</Menu.Item>
148-
))}
149-
{filtered.length === 0 && (
150-
<Menu.Item disabled className={stop}>
151-
{emptyText}
152-
</Menu.Item>
153-
)}
132+
<GroupedTemplatesMenuItems
133+
templates={templates}
134+
filter={filter}
135+
emptyText={emptyText}
136+
onApply={(template) => {
137+
onApply(template);
138+
close();
139+
}}
140+
/>
154141
</Menu>
155142
</div>
156143
</>

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/blockUtils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {blockClass, gridScopeClass, inlineToRule, scopeCss} from '../css';
1+
import {blockClass, gridScopeClass, inlineToRule, scopeCss, templateCssToRules} from '../css';
22
import type {
33
GridBlock,
44
GridBlockBlockTemplate,
@@ -11,7 +11,7 @@ export const createGridBlockId = () => Math.random().toString(36).slice(2, 10);
1111

1212
export const templateToBlock = (template: GridBlockBlockTemplate): GridBlock => ({
1313
id: createGridBlockId(),
14-
css: inlineToRule(template.block.css),
14+
css: templateCssToRules(template.block.css),
1515
content: template.block.content,
1616
});
1717

@@ -22,10 +22,10 @@ export const rawTemplateBlockToBlock = (block: GridBlockTemplateBlock): GridBloc
2222
});
2323

2424
export const containerTemplateToAttrs = (template: GridBlockContainerTemplate) => ({
25-
customCss: inlineToRule(template.containerCss, '.grid'),
25+
customCss: templateCssToRules(template.containerCss, '.grid'),
2626
blocks: template.blocks.map((block) => ({
2727
id: createGridBlockId(),
28-
css: inlineToRule(block.css),
28+
css: templateCssToRules(block.css),
2929
content: block.content,
3030
})),
3131
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type {GridBlockTemplate} from '../types';
2+
3+
import {groupTemplatesForMenu} from './groupTemplates';
4+
5+
const tpl = (id: string, title: string, group?: string): GridBlockTemplate => {
6+
const template: GridBlockTemplate = {
7+
id,
8+
title,
9+
type: 'block',
10+
content: title,
11+
block: {css: '', content: title},
12+
};
13+
14+
return group ? {...template, group} : template;
15+
};
16+
17+
describe('groupTemplatesForMenu', () => {
18+
it('keeps ungrouped templates and groups in first-seen order', () => {
19+
expect(
20+
groupTemplatesForMenu(
21+
[
22+
tpl('a', 'A'),
23+
tpl('b', 'B1', 'Group B'),
24+
tpl('c', 'C'),
25+
tpl('d', 'B2', 'Group B'),
26+
tpl('e', 'E1', 'Group E'),
27+
],
28+
'',
29+
),
30+
).toEqual([
31+
{type: 'template', template: tpl('a', 'A')},
32+
{
33+
type: 'group',
34+
title: 'Group B',
35+
templates: [tpl('b', 'B1', 'Group B'), tpl('d', 'B2', 'Group B')],
36+
},
37+
{type: 'template', template: tpl('c', 'C')},
38+
{type: 'group', title: 'Group E', templates: [tpl('e', 'E1', 'Group E')]},
39+
]);
40+
});
41+
42+
it('returns a whole group when the group title matches search', () => {
43+
expect(
44+
groupTemplatesForMenu(
45+
[tpl('a', 'Hero', 'Marketing'), tpl('b', 'Cards', 'Marketing')],
46+
'market',
47+
),
48+
).toEqual([
49+
{
50+
type: 'group',
51+
title: 'Marketing',
52+
templates: [tpl('a', 'Hero', 'Marketing'), tpl('b', 'Cards', 'Marketing')],
53+
},
54+
]);
55+
});
56+
57+
it('returns only matching templates inside a group when template titles match search', () => {
58+
expect(
59+
groupTemplatesForMenu(
60+
[tpl('a', 'Hero', 'Marketing'), tpl('b', 'Cards', 'Marketing')],
61+
'hero',
62+
),
63+
).toEqual([
64+
{type: 'group', title: 'Marketing', templates: [tpl('a', 'Hero', 'Marketing')]},
65+
]);
66+
});
67+
});

0 commit comments

Comments
 (0)