@akinon/pz-theme 2.0.71-rc.0 → 2.0.72-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ // The recursive renderer, stubbed with a countable marker so a slot's children
5
+ // are assertable without dragging every block renderer into the assertion. The
6
+ // same stand-in `public-api.test.tsx` uses, for the same reason.
7
+ jest.mock('../../theme-block', () => ({
8
+ __esModule: true,
9
+ default: ({ block }: { block: { id: string } }) => (
10
+ <div data-testid="slot-child" data-id={block.id} />
11
+ )
12
+ }));
13
+
14
+ import blockRendererRegistry from '../../blocks/block-renderer-registry';
15
+ import type { BlockRendererProps } from '../../blocks/block-renderer-registry';
16
+ import type { Block } from '../../theme-block';
17
+ import { resetCustomCatalog } from '../catalog';
18
+ import { resetCustomCollection } from '../collect';
19
+ import { defineCustomBlock } from '../define';
20
+ import { registerCustomBlocks } from '../register';
21
+ import { ThemeSlot } from '../theme-slot';
22
+ import { isCustomThemeType } from '../types';
23
+
24
+ /**
25
+ * THE OTHER HALF OF THE CROSS-REPO SEAM.
26
+ *
27
+ * The catalog travels one way — this repo describes the block and the editor
28
+ * reads it, and `custom-catalog-wire.test.ts` over there pins that direction
29
+ * with a literal capture of what `getCustomCatalog()` posts. Slot nodes travel
30
+ * the OTHER way: the editor writes them into the composition and this repo
31
+ * reads them back. Nothing in this package's own tests could notice if the two
32
+ * sides disagreed about the node, because every other slot test here builds
33
+ * its composition from this repo's own constants.
34
+ *
35
+ * `EDITOR_COMPOSITION` below is therefore not hand-written either. It is the
36
+ * literal output of the editor's `ensureCustomSlotNodes()` — the function its
37
+ * two insert reducers call — captured from pz-dynamic-widget and pasted here
38
+ * unedited, for a block declaring the same three slots the catalog capture
39
+ * over there declares.
40
+ *
41
+ * TO REGENERATE, in `pz-dynamic-widget`: ingest a catalog through
42
+ * `parseCustomCatalogPayload` + `setCustomCatalog`, call
43
+ * `ensureCustomSlotNodes()` on a freshly dropped block of that type, and log
44
+ * the block. The ids are the only values that differ per run.
45
+ *
46
+ * Read as DATA, not through this repo's constants. `CUSTOM_SLOT_BLOCK_TYPE`
47
+ * and `SLOT_NAME_PROPERTY` are exported precisely so the editor can mirror
48
+ * them, and a test that spelled the fixture through those exports would follow
49
+ * a rename here and stay green while the editor went on writing the old
50
+ * spelling — which is the one failure this file exists to catch.
51
+ */
52
+ const EDITOR_COMPOSITION = {
53
+ id: 'block-menu',
54
+ type: 'custom:acme:mega-menu',
55
+ label: 'Mega menu',
56
+ properties: {},
57
+ styles: {},
58
+ order: 0,
59
+ hidden: false,
60
+ blocks: [
61
+ {
62
+ id: 'block-l6rl9',
63
+ type: 'custom-slot',
64
+ label: 'Promo',
65
+ properties: { slotName: 'promo' },
66
+ styles: {},
67
+ order: 0,
68
+ hidden: false,
69
+ blocks: [
70
+ {
71
+ id: 'promo-child',
72
+ type: 'image',
73
+ label: 'Image',
74
+ properties: {},
75
+ styles: {},
76
+ order: 0,
77
+ hidden: false
78
+ }
79
+ ]
80
+ },
81
+ {
82
+ id: 'block-ud5p6',
83
+ type: 'custom-slot',
84
+ label: 'Links',
85
+ properties: { slotName: 'links' },
86
+ styles: {},
87
+ order: 1,
88
+ hidden: false,
89
+ blocks: [
90
+ {
91
+ id: 'links-child',
92
+ type: 'text',
93
+ label: 'Text',
94
+ properties: {},
95
+ styles: {},
96
+ order: 0,
97
+ hidden: false
98
+ }
99
+ ]
100
+ },
101
+ {
102
+ id: 'block-g4b81',
103
+ type: 'custom-slot',
104
+ label: 'Footnote',
105
+ properties: { slotName: '' },
106
+ styles: {},
107
+ order: 2,
108
+ hidden: false,
109
+ blocks: [
110
+ {
111
+ id: 'footnote-child',
112
+ type: 'text',
113
+ label: 'Text',
114
+ properties: {},
115
+ styles: {},
116
+ order: 0,
117
+ hidden: false
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ } as unknown as Block;
123
+
124
+ const TYPE = 'custom:acme:mega-menu';
125
+
126
+ const rendererMap = (): Map<string, unknown> =>
127
+ (blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
128
+ .renderers;
129
+
130
+ const clearCustomRenderers = (): void => {
131
+ const map = rendererMap();
132
+ Array.from(map.keys())
133
+ .filter(isCustomThemeType)
134
+ .forEach((type) => map.delete(type));
135
+ };
136
+
137
+ /** Declares its three slots exactly as the captured catalog frame does. */
138
+ function MegaMenu() {
139
+ return (
140
+ <nav>
141
+ <section data-testid="promo-region">
142
+ <ThemeSlot name="promo" />
143
+ </section>
144
+ <section data-testid="links-region">
145
+ <ThemeSlot name="links" />
146
+ </section>
147
+ <section data-testid="footnote-region">
148
+ {/* The unnamed slot, spelled the way every pre-named-slots block does. */}
149
+ <ThemeSlot />
150
+ </section>
151
+ </nav>
152
+ );
153
+ }
154
+
155
+ const registerMenu = () =>
156
+ registerCustomBlocks('acme', {
157
+ 'mega-menu': defineCustomBlock(MegaMenu, {
158
+ label: 'Mega menu',
159
+ slots: [
160
+ { name: 'promo', label: 'Promo', accepts: ['image', 'text'], max: 2 },
161
+ { name: 'links', label: 'Links' },
162
+ { label: 'Footnote' }
163
+ ]
164
+ })
165
+ });
166
+
167
+ const renderComposition = (block: Block, isDesigner = false) => {
168
+ const Renderer = blockRendererRegistry.getRenderer(TYPE);
169
+ if (!Renderer) throw new Error(`No renderer registered for ${TYPE}`);
170
+
171
+ const props: BlockRendererProps = {
172
+ block,
173
+ placeholderId: 'header',
174
+ sectionId: 's1',
175
+ isDesigner
176
+ };
177
+
178
+ return render(<Renderer {...props} />);
179
+ };
180
+
181
+ const childIdsIn = (regionTestId: string): string[] =>
182
+ Array.from(
183
+ screen
184
+ .getByTestId(regionTestId)
185
+ .querySelectorAll('[data-testid="slot-child"]')
186
+ ).map((element) => element.getAttribute('data-id') ?? '');
187
+
188
+ describe('the slot nodes the editor actually writes', () => {
189
+ beforeEach(() => {
190
+ resetCustomCatalog();
191
+ resetCustomCollection();
192
+ clearCustomRenderers();
193
+ });
194
+
195
+ it('each render into the ThemeSlot that names them, and no other', () => {
196
+ registerMenu();
197
+ renderComposition(EDITOR_COMPOSITION);
198
+
199
+ // The whole of gap 1 in one assertion: three regions filled
200
+ // independently, from three nodes, in one block. Before slot nodes every
201
+ // `ThemeSlot` read the same `block.blocks` array, so the second was
202
+ // refused outright.
203
+ expect(childIdsIn('promo-region')).toEqual(['promo-child']);
204
+ expect(childIdsIn('links-region')).toEqual(['links-child']);
205
+ expect(childIdsIn('footnote-region')).toEqual(['footnote-child']);
206
+ });
207
+
208
+ it('are matched on the property the editor writes, not on the label', () => {
209
+ registerMenu();
210
+
211
+ // The merchant renames a slot row from the layer tree (`RENAME_BLOCK`),
212
+ // which is the one field of a node they can change. If the name rode on
213
+ // the label, this rename would silently detach the region from its
214
+ // `<ThemeSlot name="promo" />` and nothing would say why it went empty.
215
+ const renamed = JSON.parse(
216
+ JSON.stringify(EDITOR_COMPOSITION)
217
+ ) as typeof EDITOR_COMPOSITION;
218
+ (renamed.blocks as Array<{ label: string }>)[0].label = 'Seasonal banner';
219
+
220
+ renderComposition(renamed as unknown as Block);
221
+
222
+ expect(childIdsIn('promo-region')).toEqual(['promo-child']);
223
+ });
224
+
225
+ it('leave a region the editor has never opened empty rather than wrong', () => {
226
+ registerMenu();
227
+
228
+ // The editor materializes nodes at insert, so a composition can hold some
229
+ // and not others — a block saved against an older build of the brand's
230
+ // component, or a slot added to the component after the merchant placed
231
+ // it. The missing region must draw nothing, and must NOT fall back to
232
+ // another slot's content.
233
+ const partial = JSON.parse(
234
+ JSON.stringify(EDITOR_COMPOSITION)
235
+ ) as typeof EDITOR_COMPOSITION;
236
+ partial.blocks = [partial.blocks[0]];
237
+
238
+ renderComposition(partial as unknown as Block);
239
+
240
+ expect(childIdsIn('promo-region')).toEqual(['promo-child']);
241
+ expect(childIdsIn('links-region')).toEqual([]);
242
+ expect(childIdsIn('footnote-region')).toEqual([]);
243
+ });
244
+
245
+ it('do not leak into the unnamed slot of a half-migrated composition', () => {
246
+ registerMenu();
247
+
248
+ // Every composition saved before slots were nodes put the merchant's
249
+ // blocks straight into `block.blocks`, and the unnamed slot falls back to
250
+ // them — that fallback is what keeps published content rendering. It has
251
+ // to skip slot NODES, or a block carrying both shapes would show the named
252
+ // regions' content in the unnamed one as well.
253
+ const mixed = JSON.parse(
254
+ JSON.stringify(EDITOR_COMPOSITION)
255
+ ) as typeof EDITOR_COMPOSITION;
256
+ mixed.blocks = [
257
+ mixed.blocks[0],
258
+ mixed.blocks[1],
259
+ {
260
+ id: 'legacy-child',
261
+ type: 'text',
262
+ label: 'Text',
263
+ properties: {},
264
+ styles: {},
265
+ order: 2,
266
+ hidden: false
267
+ } as unknown as typeof mixed.blocks[number]
268
+ ];
269
+
270
+ renderComposition(mixed as unknown as Block);
271
+
272
+ expect(childIdsIn('promo-region')).toEqual(['promo-child']);
273
+ expect(childIdsIn('footnote-region')).toEqual(['legacy-child']);
274
+ });
275
+
276
+ it('stop reading stray children the moment the unnamed NODE exists beside them', () => {
277
+ registerMenu();
278
+
279
+ // THE REASON THE EDITOR HAS TO MOVE THEM, pinned from the renderer's side.
280
+ //
281
+ // The legacy fallback above is a fallback: the node wins the lookup, so a
282
+ // block carrying BOTH shapes draws the node's children and nothing else.
283
+ // That is the right rule — two answers to one `<ThemeSlot/>` would render
284
+ // the region twice — but it means appending an unnamed node beside authored
285
+ // children silently takes them off the page while leaving them saved and
286
+ // listed in the layer tree. The editor's `ensureCustomSlotNodes` therefore
287
+ // adopts them into the node rather than appending beside them, and this is
288
+ // what says why.
289
+ const beside = JSON.parse(
290
+ JSON.stringify(EDITOR_COMPOSITION)
291
+ ) as typeof EDITOR_COMPOSITION;
292
+ beside.blocks = [
293
+ {
294
+ id: 'legacy-child',
295
+ type: 'text',
296
+ label: 'Text',
297
+ properties: {},
298
+ styles: {},
299
+ order: 0,
300
+ hidden: false
301
+ } as unknown as typeof beside.blocks[number],
302
+ beside.blocks[2]
303
+ ];
304
+
305
+ renderComposition(beside as unknown as Block);
306
+
307
+ expect(childIdsIn('footnote-region')).toEqual(['footnote-child']);
308
+ });
309
+ });
@@ -0,0 +1,144 @@
1
+ import type { Block } from '../../theme-block';
2
+ import {
3
+ isSlotNode,
4
+ resolveSlotChildren,
5
+ resolveUnnamedSlot,
6
+ slotNameError,
7
+ slotNodeName
8
+ } from '../slot-nodes';
9
+ import { CUSTOM_SLOT_BLOCK_TYPE, SLOT_NAME_PROPERTY } from '../types';
10
+
11
+ const block = (over: Partial<Block> = {}): Block => ({
12
+ id: 'b1',
13
+ type: 'text',
14
+ label: 'b1',
15
+ properties: {},
16
+ styles: {},
17
+ order: 0,
18
+ hidden: false,
19
+ ...over
20
+ });
21
+
22
+ const slotNode = (properties: Record<string, unknown>, blocks: Block[] = []) =>
23
+ block({
24
+ id: 'slot',
25
+ type: CUSTOM_SLOT_BLOCK_TYPE,
26
+ properties,
27
+ blocks
28
+ });
29
+
30
+ describe('slotNodeName', () => {
31
+ it('reads the bare string the editor stamps', () => {
32
+ expect(slotNodeName(slotNode({ [SLOT_NAME_PROPERTY]: 'promo' }))).toBe(
33
+ 'promo'
34
+ );
35
+ });
36
+
37
+ it('reads a per-breakpoint bucket as well', () => {
38
+ // Every other value in `block.properties` is per-breakpoint, and the panel
39
+ // re-buckets whatever it is given. A bare-string-only reader would detach
40
+ // every named slot in every published composition the day anything routed
41
+ // this key through a control.
42
+ expect(
43
+ slotNodeName(slotNode({ [SLOT_NAME_PROPERTY]: { desktop: 'promo' } }))
44
+ ).toBe('promo');
45
+ });
46
+
47
+ it('treats a node with no name as the unnamed slot', () => {
48
+ expect(slotNodeName(slotNode({}))).toBe('');
49
+ });
50
+ });
51
+
52
+ describe('resolveSlotChildren', () => {
53
+ const inPromo = block({ id: 'in-promo' });
54
+ const inAside = block({ id: 'in-aside' });
55
+
56
+ it('returns the children of the node with that name', () => {
57
+ const host = block({
58
+ blocks: [
59
+ slotNode({ [SLOT_NAME_PROPERTY]: 'promo' }, [inPromo]),
60
+ slotNode({ [SLOT_NAME_PROPERTY]: 'aside' }, [inAside])
61
+ ]
62
+ });
63
+
64
+ expect(resolveSlotChildren(host, 'promo')).toEqual([inPromo]);
65
+ expect(resolveSlotChildren(host, 'aside')).toEqual([inAside]);
66
+ });
67
+
68
+ it('separates an emptied slot from one that was never opened', () => {
69
+ const host = block({
70
+ blocks: [slotNode({ [SLOT_NAME_PROPERTY]: 'promo' }, [])]
71
+ });
72
+
73
+ // Both render nothing, so the distinction costs shoppers nothing — it
74
+ // exists so the designer can say which is which.
75
+ expect(resolveSlotChildren(host, 'promo')).toEqual([]);
76
+ expect(resolveSlotChildren(host, 'footer')).toBeNull();
77
+ });
78
+
79
+ it('gives the unnamed slot the children of a composition with no nodes', () => {
80
+ const legacy = block({ blocks: [inPromo, inAside] });
81
+
82
+ expect(resolveSlotChildren(legacy, '')).toEqual([inPromo, inAside]);
83
+ });
84
+
85
+ it('keeps another slot node out of the unnamed slot', () => {
86
+ const halfMigrated = block({
87
+ blocks: [slotNode({ [SLOT_NAME_PROPERTY]: 'promo' }, [inPromo]), inAside]
88
+ });
89
+
90
+ expect(resolveSlotChildren(halfMigrated, '')).toEqual([inAside]);
91
+ });
92
+
93
+ it('never falls back for a named slot', () => {
94
+ // A named slot cannot have legacy content to find: naming one was refused
95
+ // at define time before slots were nodes.
96
+ expect(resolveSlotChildren(block({ blocks: [inPromo] }), 'promo')).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('resolveUnnamedSlot', () => {
101
+ it('is the unnamed slot when the block declares nothing', () => {
102
+ expect(resolveUnnamedSlot([])).toEqual({ name: '' });
103
+ });
104
+
105
+ it('is the single declared slot whatever it is called', () => {
106
+ expect(resolveUnnamedSlot(['promo'])).toEqual({ name: 'promo' });
107
+ });
108
+
109
+ it('is the unnamed slot when one is declared beside named ones', () => {
110
+ expect(resolveUnnamedSlot(['', 'promo'])).toEqual({ name: '' });
111
+ });
112
+
113
+ it('refuses several named slots and names them all', () => {
114
+ const resolved = resolveUnnamedSlot(['promo', 'aside']) as {
115
+ error: string;
116
+ };
117
+
118
+ expect(resolved.error).toContain('<ThemeSlot name="promo" />');
119
+ expect(resolved.error).toContain('<ThemeSlot name="aside" />');
120
+ });
121
+ });
122
+
123
+ describe('slotNameError', () => {
124
+ it('accepts the empty name and a hyphenated one', () => {
125
+ expect(slotNameError('')).toBeNull();
126
+ expect(slotNameError('promo-content')).toBeNull();
127
+ });
128
+
129
+ it('refuses anything that would not match a node literally', () => {
130
+ expect(slotNameError('Promo Content')).toContain('is not valid');
131
+ expect(slotNameError('1promo')).toContain('is not valid');
132
+ expect(slotNameError(undefined)).toContain('is not valid');
133
+ });
134
+ });
135
+
136
+ describe('isSlotNode', () => {
137
+ it('recognises the reserved type and nothing else', () => {
138
+ expect(isSlotNode(slotNode({}))).toBe(true);
139
+ expect(isSlotNode(block({ type: 'group' }))).toBe(false);
140
+ // The slot type deliberately sits outside the `custom:<vendor>:<name>`
141
+ // grammar: nothing renders it, so it is not a brand block type.
142
+ expect(isSlotNode(block({ type: 'custom:acme:promo-strip' }))).toBe(false);
143
+ });
144
+ });