@akinon/pz-theme 2.0.78-rc.1 → 2.0.78
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.
- package/CHANGELOG.md +12 -39
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +73 -0
- package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
- package/src/chrome/chrome-mode.ts +61 -0
- package/src/custom/README-sections.md +289 -0
- package/src/custom/README.md +7 -0
- package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
- package/src/custom/__tests__/define-section.test.tsx +481 -0
- package/src/custom/__tests__/module-boundaries.test.ts +2 -0
- package/src/custom/__tests__/public-api.test.tsx +123 -0
- package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
- package/src/custom/__tests__/region-nodes.test.ts +161 -0
- package/src/custom/__tests__/theme-editable.test.tsx +22 -10
- package/src/custom/__tests__/theme-region.test.tsx +331 -0
- package/src/custom/__tests__/theme-slot.test.tsx +18 -10
- package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
- package/src/custom/block-context.ts +53 -10
- package/src/custom/block-host.tsx +13 -1
- package/src/custom/collect.ts +26 -5
- package/src/custom/define-section.tsx +163 -0
- package/src/custom/index.ts +83 -1
- package/src/custom/region-nodes.ts +72 -0
- package/src/custom/register.ts +419 -48
- package/src/custom/section-catalog.ts +98 -0
- package/src/custom/section-definitions.ts +59 -0
- package/src/custom/section-host.tsx +125 -0
- package/src/custom/section-types.ts +158 -0
- package/src/custom/theme-editable.tsx +3 -1
- package/src/custom/theme-region.tsx +213 -0
- package/src/custom/theme-slot.tsx +8 -7
- package/src/custom/types.ts +10 -1
- package/src/custom/use-theme-setting.ts +6 -4
- package/src/hooks/use-designer-features.tsx +41 -0
- package/src/index.ts +25 -1
- package/src/placeholder-registry.ts +34 -5
- package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
- package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
- package/src/sections/custom-renderer-section-types.ts +19 -0
- package/src/sections/section-wrapper.tsx +12 -1
- package/src/theme-section.tsx +162 -3
- package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
- package/src/utils/__tests__/section-content-width.test.ts +28 -0
- package/src/utils/__tests__/section-max-width.test.ts +53 -0
- package/src/utils/index.ts +31 -3
- 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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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)
|
|
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}>
|