@akinon/pz-theme 2.0.78-rc.2 → 2.0.79-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.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -37
  2. package/package.json +4 -4
  3. package/src/__tests__/custom-catalog-announce.test.ts +73 -0
  4. package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
  5. package/src/chrome/chrome-mode.ts +61 -0
  6. package/src/custom/README-sections.md +289 -0
  7. package/src/custom/README.md +7 -0
  8. package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
  9. package/src/custom/__tests__/define-section.test.tsx +481 -0
  10. package/src/custom/__tests__/module-boundaries.test.ts +2 -0
  11. package/src/custom/__tests__/public-api.test.tsx +123 -0
  12. package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
  13. package/src/custom/__tests__/region-nodes.test.ts +161 -0
  14. package/src/custom/__tests__/theme-editable.test.tsx +22 -10
  15. package/src/custom/__tests__/theme-region.test.tsx +331 -0
  16. package/src/custom/__tests__/theme-slot.test.tsx +18 -10
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
  18. package/src/custom/block-context.ts +53 -10
  19. package/src/custom/block-host.tsx +13 -1
  20. package/src/custom/collect.ts +26 -5
  21. package/src/custom/define-section.tsx +163 -0
  22. package/src/custom/index.ts +83 -1
  23. package/src/custom/region-nodes.ts +72 -0
  24. package/src/custom/register.ts +419 -48
  25. package/src/custom/section-catalog.ts +98 -0
  26. package/src/custom/section-definitions.ts +59 -0
  27. package/src/custom/section-host.tsx +125 -0
  28. package/src/custom/section-types.ts +158 -0
  29. package/src/custom/theme-editable.tsx +3 -1
  30. package/src/custom/theme-region.tsx +213 -0
  31. package/src/custom/theme-slot.tsx +8 -7
  32. package/src/custom/types.ts +10 -1
  33. package/src/custom/use-theme-setting.ts +6 -4
  34. package/src/hooks/use-designer-features.tsx +41 -0
  35. package/src/index.ts +25 -1
  36. package/src/placeholder-registry.ts +34 -5
  37. package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
  38. package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
  39. package/src/sections/custom-renderer-section-types.ts +19 -0
  40. package/src/sections/section-wrapper.tsx +12 -1
  41. package/src/theme-section.tsx +162 -3
  42. package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
  43. package/src/utils/__tests__/section-content-width.test.ts +28 -0
  44. package/src/utils/__tests__/section-max-width.test.ts +53 -0
  45. package/src/utils/index.ts +31 -3
  46. package/src/utils/part-styles.ts +17 -1
@@ -0,0 +1,302 @@
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 region's block
5
+ // is assertable without dragging every block renderer into the assertion. The
6
+ // same stand-in `slot-node-wire.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="region-child" data-id={block.id} />
11
+ )
12
+ }));
13
+
14
+ import blockRendererRegistry from '../../blocks/block-renderer-registry';
15
+ import ThemeSection from '../../theme-section';
16
+ import type { Section } from '../../theme-section';
17
+ import { ThemeSettingsProvider } from '../../theme-settings-context';
18
+ import { resetCustomCatalog } from '../catalog';
19
+ import { resetCustomCollection } from '../collect';
20
+ import { defineCustomSection } from '../define-section';
21
+ import { registerCustomSections } from '../register';
22
+ import { resetCustomSectionCatalog } from '../section-catalog';
23
+ import { resetCustomSectionDefinitions } from '../section-definitions';
24
+ import { ThemeRegion } from '../theme-region';
25
+ import { ThemeSlot } from '../theme-slot';
26
+ import { isCustomThemeType } from '../types';
27
+
28
+ /**
29
+ * THE OTHER HALF OF THE CROSS-REPO SEAM, for sections.
30
+ *
31
+ * The catalog travels one way — this repo describes the section and the editor
32
+ * reads it. Region blocks travel the OTHER way: the editor seeds them into the
33
+ * composition and this repo reads them back. Nothing in this package's own
34
+ * tests could notice if the two sides disagreed about the seeded shape, because
35
+ * every other region test here builds its composition from this repo's own
36
+ * constants.
37
+ *
38
+ * `EDITOR_COMPOSITION` below is therefore read as DATA. It is the shape the
39
+ * editor's section insert writes for a section whose descriptor declares the
40
+ * three regions and the one slot the component below places: an ORDINARY block
41
+ * of the declared type per region, carrying `properties.regionName`, plus a
42
+ * `custom-slot` node for the slot. A region is NOT a wrapper node, and that
43
+ * asymmetry with slots is the whole design — the merchant opens the row and
44
+ * gets the `image`/`search-box`/`mini-basket` panel itself.
45
+ *
46
+ * TO REGENERATE, in `pz-dynamic-widget`: ingest a section catalog through
47
+ * `parseCustomSectionCatalogPayload` + `setCustomSectionCatalog`, add a section
48
+ * of that type so the insert reducer seeds its regions from
49
+ * `getCustomSectionRegions(type)`, and log `section.blocks`. The ids are the
50
+ * only values that differ per run.
51
+ *
52
+ * Written WITHOUT this repo's own constants. `REGION_NAME_PROPERTY` is exported
53
+ * precisely so the editor can mirror it, and a fixture spelled through that
54
+ * export would follow a rename here and stay green while the editor went on
55
+ * seeding the old spelling — which is the one failure this file exists to
56
+ * catch.
57
+ */
58
+ const EDITOR_COMPOSITION = {
59
+ id: 'section-header',
60
+ type: 'custom:zsazsazsu:header',
61
+ name: 'ZSA ZSA ZSU Header',
62
+ label: 'ZSA ZSA ZSU Header',
63
+ properties: {},
64
+ styles: {},
65
+ order: 0,
66
+ hidden: false,
67
+ blocks: [
68
+ {
69
+ id: 'block-h3n1a',
70
+ type: 'image',
71
+ label: 'Logo',
72
+ properties: { regionName: 'logo' },
73
+ styles: {},
74
+ order: 0,
75
+ hidden: false
76
+ },
77
+ {
78
+ id: 'block-k92rd',
79
+ type: 'search-box',
80
+ label: 'Store search',
81
+ properties: { regionName: 'search' },
82
+ styles: {},
83
+ order: 1,
84
+ hidden: false
85
+ },
86
+ {
87
+ id: 'block-p47xv',
88
+ type: 'mini-basket',
89
+ label: 'Basket',
90
+ properties: { regionName: 'basket' },
91
+ styles: {},
92
+ order: 2,
93
+ hidden: false
94
+ },
95
+ {
96
+ id: 'block-t6qz0',
97
+ type: 'custom-slot',
98
+ label: 'Extra header actions',
99
+ properties: { slotName: 'extras' },
100
+ styles: {},
101
+ order: 3,
102
+ hidden: false,
103
+ blocks: [
104
+ {
105
+ id: 'extras-child',
106
+ type: 'text',
107
+ label: 'Text',
108
+ properties: {},
109
+ styles: {},
110
+ order: 0,
111
+ hidden: false
112
+ }
113
+ ]
114
+ }
115
+ ]
116
+ } as unknown as Section;
117
+
118
+ const TYPE = 'custom:zsazsazsu:header';
119
+
120
+ const rendererMap = (): Map<string, unknown> =>
121
+ (blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
122
+ .renderers;
123
+
124
+ const clearCustomRenderers = (): void => {
125
+ const map = rendererMap();
126
+ Array.from(map.keys())
127
+ .filter(isCustomThemeType)
128
+ .forEach((type) => map.delete(type));
129
+ };
130
+
131
+ /** Places its three regions and its one slot exactly as the descriptor declares them. */
132
+ function ZsaHeader() {
133
+ return (
134
+ <header>
135
+ <div data-testid="logo-area">
136
+ <ThemeRegion name="logo" />
137
+ </div>
138
+ <div data-testid="search-area">
139
+ <ThemeRegion name="search" label="Store search" />
140
+ </div>
141
+ <div data-testid="basket-area">
142
+ <ThemeRegion name="basket" />
143
+ </div>
144
+ <div data-testid="extras-area">
145
+ <ThemeSlot name="extras" label="Extra header actions" />
146
+ </div>
147
+ </header>
148
+ );
149
+ }
150
+
151
+ const registerHeader = () =>
152
+ registerCustomSections('zsazsazsu', {
153
+ header: defineCustomSection(ZsaHeader, {
154
+ label: 'ZSA ZSA ZSU Header',
155
+ regions: [
156
+ { name: 'logo', label: 'Logo', block: 'image' },
157
+ { name: 'search', label: 'Store search', block: 'search-box' },
158
+ { name: 'basket', label: 'Basket', block: 'mini-basket' }
159
+ ],
160
+ slots: [{ name: 'extras', label: 'Extra header actions' }],
161
+ lockRootTransforms: true
162
+ })
163
+ });
164
+
165
+ const renderComposition = (section: Section, isDesigner = false) =>
166
+ render(
167
+ <ThemeSettingsProvider value={null}>
168
+ <ThemeSection
169
+ section={section}
170
+ placeholderId="header"
171
+ currentBreakpoint="desktop"
172
+ isDesigner={isDesigner}
173
+ />
174
+ </ThemeSettingsProvider>
175
+ );
176
+
177
+ const childIdsIn = (areaTestId: string): string[] =>
178
+ Array.from(
179
+ screen
180
+ .getByTestId(areaTestId)
181
+ .querySelectorAll('[data-testid="region-child"]')
182
+ ).map((element) => element.getAttribute('data-id') ?? '');
183
+
184
+ /** A deep copy, so one test's edit cannot reach the next one's fixture. */
185
+ const composition = (): Section =>
186
+ JSON.parse(JSON.stringify(EDITOR_COMPOSITION)) as Section;
187
+
188
+ describe('the region blocks the editor actually seeds', () => {
189
+ beforeEach(() => {
190
+ resetCustomCatalog();
191
+ resetCustomCollection();
192
+ resetCustomSectionCatalog();
193
+ resetCustomSectionDefinitions();
194
+ clearCustomRenderers();
195
+ });
196
+
197
+ it('each render into the ThemeRegion that names them, and no other', () => {
198
+ registerHeader();
199
+ renderComposition(composition());
200
+
201
+ // Three regions placed independently, from three ordinary blocks in one
202
+ // section, in the order the JSX chose rather than the order they are
203
+ // seeded in — the layer tree cannot move any of them.
204
+ expect(childIdsIn('logo-area')).toEqual(['block-h3n1a']);
205
+ expect(childIdsIn('search-area')).toEqual(['block-k92rd']);
206
+ expect(childIdsIn('basket-area')).toEqual(['block-p47xv']);
207
+ });
208
+
209
+ it('keep the slot in the same section resolving against its own node', () => {
210
+ registerHeader();
211
+ renderComposition(composition());
212
+
213
+ // Two grammars in one section: regions matched on `regionName` on an
214
+ // ordinary block, slots matched on `slotName` on a `custom-slot` wrapper.
215
+ // Crossing them would put the slot's wrapper into a region, or a region's
216
+ // block into the slot.
217
+ expect(childIdsIn('extras-area')).toEqual(['extras-child']);
218
+ });
219
+
220
+ it('are matched on the property the editor writes, not on the label', () => {
221
+ registerHeader();
222
+
223
+ // `RENAME_BLOCK` is the one field of a seeded region a merchant can change
224
+ // from the layer tree. If the name rode on the label, this rename would
225
+ // silently detach the block from its `<ThemeRegion name="search" />` and
226
+ // nothing would say why the area went empty.
227
+ const renamed = composition();
228
+ (renamed.blocks as Array<{ label: string }>)[1].label = 'Find a store';
229
+
230
+ renderComposition(renamed);
231
+
232
+ expect(childIdsIn('search-area')).toEqual(['block-k92rd']);
233
+ });
234
+
235
+ it('leave a region the merchant deleted empty rather than wrong', () => {
236
+ registerHeader();
237
+
238
+ // Deleting a region is an ordinary composition edit and stays durable:
239
+ // nothing re-seeds it on the next load. The other two are unaffected.
240
+ const withoutSearch = composition();
241
+ withoutSearch.blocks = withoutSearch.blocks.filter(
242
+ (block) => block.id !== 'block-k92rd'
243
+ );
244
+
245
+ renderComposition(withoutSearch);
246
+
247
+ expect(childIdsIn('search-area')).toEqual([]);
248
+ expect(childIdsIn('logo-area')).toEqual(['block-h3n1a']);
249
+ expect(childIdsIn('basket-area')).toEqual(['block-p47xv']);
250
+ });
251
+
252
+ it('never adopt a block the merchant added to the section itself', () => {
253
+ registerHeader();
254
+
255
+ // THE ANTI-SLOT-FALLBACK INVARIANT ON REAL DATA. `resolveSlotChildren` has
256
+ // a legacy branch that reads a block's direct children as its unnamed
257
+ // slot's content, and nothing like it can exist for regions: a section's
258
+ // direct children are the merchant's own blocks, so a region falling back
259
+ // to them would draw a loose banner inside the logo area — or, with the
260
+ // region deleted, draw it three times over.
261
+ const withStray = composition();
262
+ withStray.blocks = [
263
+ ...withStray.blocks.filter((block) => block.id !== 'block-k92rd'),
264
+ {
265
+ id: 'merchant-banner',
266
+ type: 'text',
267
+ label: 'Free shipping',
268
+ properties: {},
269
+ styles: {},
270
+ order: 4,
271
+ hidden: false
272
+ } as unknown as Section['blocks'][number]
273
+ ];
274
+
275
+ renderComposition(withStray);
276
+
277
+ expect(childIdsIn('search-area')).toEqual([]);
278
+ expect(childIdsIn('logo-area')).toEqual(['block-h3n1a']);
279
+ expect(childIdsIn('basket-area')).toEqual(['block-p47xv']);
280
+ expect(childIdsIn('extras-area')).toEqual(['extras-child']);
281
+ });
282
+
283
+ it('are never spilled onto the page by a build that cannot draw the section', () => {
284
+ const warn = jest
285
+ .spyOn(console, 'warn')
286
+ .mockImplementation(() => undefined);
287
+
288
+ // The composition holds a section whose barrel was not imported — a stale
289
+ // published widget, or a client root that lost the import. The generic
290
+ // block loop must NOT take over: it would spill the seeded regions onto
291
+ // the page as loose top-level content, in an order the developer never
292
+ // chose.
293
+ const { container } = renderComposition(composition());
294
+
295
+ expect(container).toBeEmptyDOMElement();
296
+ expect(warn).toHaveBeenCalledWith(
297
+ expect.stringContaining(`No renderer registered for custom section type: "${TYPE}"`)
298
+ );
299
+
300
+ warn.mockRestore();
301
+ });
302
+ });
@@ -0,0 +1,161 @@
1
+ import type { Block } from '../../theme-block';
2
+ import { findRegionChild, isRegionNode, regionNodeName } from '../region-nodes';
3
+ import { REGION_NAME_PROPERTY } from '../section-types';
4
+ import { CUSTOM_SLOT_BLOCK_TYPE, SLOT_NAME_PROPERTY } from '../types';
5
+
6
+ const block = (over: Partial<Block> = {}): Block => ({
7
+ id: 'b1',
8
+ type: 'text',
9
+ label: 'b1',
10
+ properties: {},
11
+ styles: {},
12
+ order: 0,
13
+ hidden: false,
14
+ ...over
15
+ });
16
+
17
+ /**
18
+ * A seeded region exactly as the editor writes one: an ORDINARY block of a real
19
+ * type — an `image`, a `search-box` — carrying the reserved name property. It
20
+ * is not a wrapper node, which is the whole point: the merchant opens it and
21
+ * gets that block type's own panel.
22
+ */
23
+ const regionBlock = (name: string, over: Partial<Block> = {}): Block =>
24
+ block({
25
+ id: `region-${name}`,
26
+ type: 'image',
27
+ label: name,
28
+ properties: { [REGION_NAME_PROPERTY]: name },
29
+ ...over
30
+ });
31
+
32
+ describe('regionNodeName', () => {
33
+ it('reads the bare string the editor stamps', () => {
34
+ expect(regionNodeName(regionBlock('search'))).toBe('search');
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 handed. A bare-string-only reader would detach
40
+ // every region in every published composition the day anything routed this
41
+ // key through a property control.
42
+ expect(
43
+ regionNodeName(
44
+ block({ properties: { [REGION_NAME_PROPERTY]: { desktop: 'search' } } })
45
+ )
46
+ ).toBe('search');
47
+ });
48
+
49
+ it('reads the desktop entry and not merely the first one in the bucket', () => {
50
+ // The bucket has no ordering guarantee across a JSON round trip, so a
51
+ // reader taking `Object.values(raw)[0]` would resolve a different region
52
+ // depending on how the composition happened to be serialized.
53
+ expect(
54
+ regionNodeName(
55
+ block({
56
+ properties: { [REGION_NAME_PROPERTY]: { mobile: 'search' } }
57
+ })
58
+ )
59
+ ).toBe('');
60
+ });
61
+
62
+ it('is empty for an ordinary composed block', () => {
63
+ expect(regionNodeName(block())).toBe('');
64
+ expect(regionNodeName(block({ properties: undefined }))).toBe('');
65
+ });
66
+ });
67
+
68
+ describe('isRegionNode', () => {
69
+ it('recognises a block carrying the name and nothing else', () => {
70
+ expect(isRegionNode(regionBlock('search'))).toBe(true);
71
+ expect(isRegionNode(block({ type: 'image' }))).toBe(false);
72
+ });
73
+
74
+ it('does not mistake a slot node for a region', () => {
75
+ // The two grammars sit side by side in one composition — a custom section
76
+ // may declare both — and they are matched on different properties. A
77
+ // reader that crossed them would hand a slot's node to `<ThemeRegion>`,
78
+ // which renders the wrapper instead of the merchant's blocks.
79
+ expect(
80
+ isRegionNode(
81
+ block({
82
+ type: CUSTOM_SLOT_BLOCK_TYPE,
83
+ properties: { [SLOT_NAME_PROPERTY]: 'extras' }
84
+ })
85
+ )
86
+ ).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe('findRegionChild', () => {
91
+ const search = regionBlock('search');
92
+ const basket = regionBlock('basket');
93
+
94
+ it('returns the child seeded for that name and no other', () => {
95
+ const section = { blocks: [search, basket] };
96
+
97
+ expect(findRegionChild(section, 'search')).toBe(search);
98
+ expect(findRegionChild(section, 'basket')).toBe(basket);
99
+ });
100
+
101
+ it('matches on the property the editor writes, not on the label', () => {
102
+ // `RENAME_BLOCK` is the one field of a seeded region a merchant can change
103
+ // from the layer tree. If the name rode on the label, renaming the row
104
+ // would detach the region from its `<ThemeRegion name="search" />` and the
105
+ // area would go empty with nothing anywhere to say why.
106
+ const renamed = regionBlock('search', { label: 'Store finder' });
107
+
108
+ expect(findRegionChild({ blocks: [renamed] }, 'search')).toBe(renamed);
109
+ });
110
+
111
+ it('returns null for a name nothing carries', () => {
112
+ // Not an error: deleting a region is an ordinary composition edit and has
113
+ // to stay durable. The caller decides what to draw, and only the designer
114
+ // draws anything.
115
+ expect(findRegionChild({ blocks: [search] }, 'basket')).toBeNull();
116
+ });
117
+
118
+ it('NEVER falls back to the section ordinary children — there is no unnamed region', () => {
119
+ // THE ANTI-SLOT-FALLBACK INVARIANT, and the single highest-risk seam in the
120
+ // feature. `resolveSlotChildren` has a legacy branch that reads a block's
121
+ // direct children as the content of its unnamed slot, and this resolver is
122
+ // one refactor away from growing the same branch. It must not: a section's
123
+ // direct children are the merchant's OWN composed blocks, so a bare or
124
+ // unmatched region falling back to them would swallow the whole section
125
+ // and render it inside whatever `<ThemeRegion>` asked first.
126
+ const composed = { blocks: [block({ id: 'merchant-hero' })] };
127
+
128
+ expect(findRegionChild(composed, 'search')).toBeNull();
129
+ expect(findRegionChild(composed, '')).toBeNull();
130
+ // Even with a real region beside them, the merchant's own blocks are never
131
+ // the answer to a name that was not seeded.
132
+ expect(findRegionChild({ blocks: [search, ...composed.blocks] }, 'logo'))
133
+ .toBeNull();
134
+ });
135
+
136
+ it('answers nothing for the empty name even when a child carries one', () => {
137
+ // A section declaring no regions still has children. An empty name is the
138
+ // shape a developer's `<ThemeRegion name={undefined as string} />` reaches
139
+ // here as, and resolving it onto the first region would place the wrong
140
+ // block in the wrong part of the markup.
141
+ expect(findRegionChild({ blocks: [search] }, '')).toBeNull();
142
+ });
143
+
144
+ it('takes the first match when a region row was duplicated', () => {
145
+ // Reachable only by duplicating a region row in the layer tree. Rendering
146
+ // both would put two `data-block-id` wrappers under one `<ThemeRegion>`,
147
+ // and the second is unreachable from the JSX either way.
148
+ const duplicate = regionBlock('search', { id: 'region-search-copy' });
149
+
150
+ expect(findRegionChild({ blocks: [search, duplicate] }, 'search')).toBe(
151
+ search
152
+ );
153
+ });
154
+
155
+ it('survives a container with no children at all', () => {
156
+ expect(findRegionChild({ blocks: [] }, 'search')).toBeNull();
157
+ expect(findRegionChild({}, 'search')).toBeNull();
158
+ expect(findRegionChild(null, 'search')).toBeNull();
159
+ expect(findRegionChild(undefined, 'search')).toBeNull();
160
+ });
161
+ });
@@ -8,8 +8,8 @@ import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
8
8
 
9
9
  const contextValue = (
10
10
  over: Partial<CustomBlockContextValue> = {}
11
- ): CustomBlockContextValue => ({
12
- block: {
11
+ ): CustomBlockContextValue => {
12
+ const block = {
13
13
  id: 'b1',
14
14
  type: 'custom:acme:promo-strip',
15
15
  label: 'Promo strip',
@@ -17,13 +17,21 @@ const contextValue = (
17
17
  styles: {},
18
18
  order: 0,
19
19
  hidden: false
20
- },
21
- placeholderId: 'header',
22
- sectionId: 's1',
23
- isDesigner: false,
24
- currentBreakpoint: 'desktop',
25
- ...over
26
- });
20
+ };
21
+
22
+ // `node` is what the helpers read and `block` is the compatibility alias;
23
+ // they are the SAME object here, which is what a block host provides.
24
+ return {
25
+ node: block,
26
+ scope: 'block',
27
+ block,
28
+ placeholderId: 'header',
29
+ sectionId: 's1',
30
+ isDesigner: false,
31
+ currentBreakpoint: 'desktop',
32
+ ...over
33
+ };
34
+ };
27
35
 
28
36
  /** Renders inside a custom block whose properties are the merchant's values. */
29
37
  const renderInBlock = (
@@ -35,7 +43,11 @@ const renderInBlock = (
35
43
  const { properties, ...rest } = over;
36
44
  const value = contextValue(rest);
37
45
 
38
- if (properties) value.block = { ...value.block, properties };
46
+ if (properties) {
47
+ const next = { ...value.block, properties };
48
+ value.block = next;
49
+ value.node = next;
50
+ }
39
51
 
40
52
  return render(
41
53
  <CustomBlockContext.Provider value={value}>