@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.
- package/CHANGELOG.md +10 -37
- 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,331 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock (default export) with a countable marker, the
|
|
5
|
+
// same way theme-slot.test.tsx does — a region is only honest if the block it
|
|
6
|
+
// hands over is the one that reaches the renderer.
|
|
7
|
+
jest.mock('../../theme-block', () => ({
|
|
8
|
+
__esModule: true,
|
|
9
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
10
|
+
<div data-testid="tb" data-id={block.id} />
|
|
11
|
+
)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { CustomBlockContext } from '../block-context';
|
|
15
|
+
import type { CustomBlockContextValue } from '../block-context';
|
|
16
|
+
import { REGION_NAME_PROPERTY } from '../section-types';
|
|
17
|
+
import type { Block } from '../../theme-block';
|
|
18
|
+
import { ThemeRegion } from '../theme-region';
|
|
19
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A seeded region as the editor writes one: an ORDINARY block of a real type
|
|
23
|
+
* carrying the reserved name property. There is no wrapper node — the merchant
|
|
24
|
+
* opens this row and gets the `image`/`mini-basket` panel itself.
|
|
25
|
+
*/
|
|
26
|
+
const regionBlock = (name: string, over: Partial<Block> = {}): Block => ({
|
|
27
|
+
id: `region-${name}`,
|
|
28
|
+
type: 'image',
|
|
29
|
+
label: name,
|
|
30
|
+
value: '',
|
|
31
|
+
properties: { [REGION_NAME_PROPERTY]: name },
|
|
32
|
+
styles: {},
|
|
33
|
+
order: 0,
|
|
34
|
+
hidden: false,
|
|
35
|
+
...over
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const contextValue = ({
|
|
39
|
+
blocks,
|
|
40
|
+
isDesigner = false,
|
|
41
|
+
scope = 'section',
|
|
42
|
+
declaredRegionNames
|
|
43
|
+
}: {
|
|
44
|
+
blocks?: Block[];
|
|
45
|
+
isDesigner?: boolean;
|
|
46
|
+
scope?: 'block' | 'section';
|
|
47
|
+
declaredRegionNames?: string[];
|
|
48
|
+
} = {}): CustomBlockContextValue => {
|
|
49
|
+
const node = {
|
|
50
|
+
id: 'sec-1',
|
|
51
|
+
type:
|
|
52
|
+
scope === 'section' ? 'custom:acme:header' : 'custom:acme:promo-strip',
|
|
53
|
+
label: 'Header',
|
|
54
|
+
properties: {},
|
|
55
|
+
styles: {},
|
|
56
|
+
order: 0,
|
|
57
|
+
hidden: false,
|
|
58
|
+
blocks
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// `node` is what the helpers read and `block` is the compatibility alias;
|
|
62
|
+
// they are the SAME object here, which is what a section host provides.
|
|
63
|
+
return {
|
|
64
|
+
node,
|
|
65
|
+
scope,
|
|
66
|
+
block: node as unknown as Block,
|
|
67
|
+
placeholderId: 'header',
|
|
68
|
+
sectionId: 'sec-1',
|
|
69
|
+
isDesigner,
|
|
70
|
+
currentBreakpoint: 'desktop',
|
|
71
|
+
...(declaredRegionNames ? { declaredRegionNames } : {})
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const renderRegion = (
|
|
76
|
+
value: CustomBlockContextValue | null,
|
|
77
|
+
region: React.ReactNode = <ThemeRegion name="search" />
|
|
78
|
+
) =>
|
|
79
|
+
render(
|
|
80
|
+
value === null ? (
|
|
81
|
+
<>{region}</>
|
|
82
|
+
) : (
|
|
83
|
+
<CustomBlockContext.Provider value={value}>
|
|
84
|
+
{region}
|
|
85
|
+
</CustomBlockContext.Provider>
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const markers = (container: HTMLElement) =>
|
|
90
|
+
Array.from(container.querySelectorAll('[data-testid="tb"]'));
|
|
91
|
+
|
|
92
|
+
const bands = (container: HTMLElement) =>
|
|
93
|
+
Array.from(container.querySelectorAll('[data-theme-block-placeholder]'));
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
resetDevWarnCacheForTests();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('ThemeRegion — the seeded block', () => {
|
|
100
|
+
it('renders it through ThemeBlock, where the JSX put the region', () => {
|
|
101
|
+
const { container } = renderRegion(
|
|
102
|
+
contextValue({ blocks: [regionBlock('search')] })
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
106
|
+
'region-search'
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('places each declared region independently in one render', () => {
|
|
111
|
+
const { container } = renderRegion(
|
|
112
|
+
contextValue({
|
|
113
|
+
blocks: [regionBlock('search'), regionBlock('basket')],
|
|
114
|
+
declaredRegionNames: ['search', 'basket']
|
|
115
|
+
}),
|
|
116
|
+
<>
|
|
117
|
+
<ThemeRegion name="basket" />
|
|
118
|
+
<ThemeRegion name="search" />
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// The JSX owns the order, not the composition: `basket` is seeded second
|
|
123
|
+
// and rendered first, and no drag in the layer tree can change that.
|
|
124
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
125
|
+
'region-basket',
|
|
126
|
+
'region-search'
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('never renders a merchant composed block for a region it does not carry', () => {
|
|
131
|
+
// The anti-slot-fallback invariant, from the component side. A section's
|
|
132
|
+
// direct children are the merchant's own blocks; a region that fell back
|
|
133
|
+
// to them would draw the entire section inside itself.
|
|
134
|
+
const { container } = renderRegion(
|
|
135
|
+
contextValue({
|
|
136
|
+
blocks: [
|
|
137
|
+
{ ...regionBlock('search'), id: 'merchant-hero', properties: {} }
|
|
138
|
+
],
|
|
139
|
+
isDesigner: true,
|
|
140
|
+
declaredRegionNames: ['search']
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(markers(container)).toHaveLength(0);
|
|
145
|
+
expect(bands(container)).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('ThemeRegion — a region the merchant deleted', () => {
|
|
150
|
+
it('draws a labelled band in the designer so the hole can be filled', () => {
|
|
151
|
+
const { container } = renderRegion(
|
|
152
|
+
contextValue({
|
|
153
|
+
blocks: [],
|
|
154
|
+
isDesigner: true,
|
|
155
|
+
declaredRegionNames: ['search']
|
|
156
|
+
}),
|
|
157
|
+
<ThemeRegion name="search" label="Store search" />
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(bands(container)).toHaveLength(1);
|
|
161
|
+
expect(container.textContent).toContain('Store search');
|
|
162
|
+
expect(container.textContent).toContain('removed');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders nothing at all on the live site', () => {
|
|
166
|
+
// Deleting a region is an ordinary composition edit and has to stay
|
|
167
|
+
// durable — a header whose store search was removed does not grow one back
|
|
168
|
+
// on the next load.
|
|
169
|
+
const { container } = renderRegion(
|
|
170
|
+
contextValue({ blocks: [], declaredRegionNames: ['search'] })
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(container).toBeEmptyDOMElement();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('ThemeRegion — a hidden region block', () => {
|
|
178
|
+
it('renders nothing live and stays selectable in the designer', () => {
|
|
179
|
+
const blocks = [regionBlock('search', { hidden: true })];
|
|
180
|
+
|
|
181
|
+
const live = renderRegion(contextValue({ blocks }));
|
|
182
|
+
expect(live.container).toBeEmptyDOMElement();
|
|
183
|
+
|
|
184
|
+
// Emptiness is decided AFTER the child pipeline, not from the array
|
|
185
|
+
// length: the designer keeps hidden children mounted so the layer tree can
|
|
186
|
+
// still reach them, and both sides draw what they actually rendered.
|
|
187
|
+
const designer = renderRegion(contextValue({ blocks, isDesigner: true }));
|
|
188
|
+
expect(markers(designer.container)).toHaveLength(1);
|
|
189
|
+
expect(bands(designer.container)).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('ThemeRegion — the same region placed twice', () => {
|
|
194
|
+
it('renders the seeded block once, not twice', () => {
|
|
195
|
+
const warn = jest
|
|
196
|
+
.spyOn(console, 'warn')
|
|
197
|
+
.mockImplementation(() => undefined);
|
|
198
|
+
|
|
199
|
+
const { container } = renderRegion(
|
|
200
|
+
contextValue({ blocks: [regionBlock('search')] }),
|
|
201
|
+
<>
|
|
202
|
+
<ThemeRegion name="search" />
|
|
203
|
+
<ThemeRegion name="search" />
|
|
204
|
+
</>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Both resolve to the SAME block, so rendering both would answer one
|
|
208
|
+
// `data-block-id` with two wrappers and break selection on the canvas.
|
|
209
|
+
expect(markers(container)).toHaveLength(1);
|
|
210
|
+
expect(warn).toHaveBeenCalledWith(
|
|
211
|
+
expect.stringContaining('more than once')
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
warn.mockRestore();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('explains itself on the designer canvas rather than going blank', () => {
|
|
218
|
+
const warn = jest
|
|
219
|
+
.spyOn(console, 'warn')
|
|
220
|
+
.mockImplementation(() => undefined);
|
|
221
|
+
|
|
222
|
+
const { container } = renderRegion(
|
|
223
|
+
contextValue({ blocks: [regionBlock('search')], isDesigner: true }),
|
|
224
|
+
<>
|
|
225
|
+
<ThemeRegion name="search" />
|
|
226
|
+
<ThemeRegion name="search" />
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(markers(container)).toHaveLength(1);
|
|
231
|
+
expect(bands(container)).toHaveLength(1);
|
|
232
|
+
expect(container.textContent).toContain('Duplicate region');
|
|
233
|
+
|
|
234
|
+
warn.mockRestore();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('ThemeRegion — placed where it cannot resolve', () => {
|
|
239
|
+
it('renders nothing outside a custom section and says so', () => {
|
|
240
|
+
const warn = jest
|
|
241
|
+
.spyOn(console, 'warn')
|
|
242
|
+
.mockImplementation(() => undefined);
|
|
243
|
+
|
|
244
|
+
const { container } = renderRegion(null);
|
|
245
|
+
|
|
246
|
+
expect(container).toBeEmptyDOMElement();
|
|
247
|
+
expect(warn).toHaveBeenCalledWith(
|
|
248
|
+
expect.stringContaining('outside a custom section')
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
warn.mockRestore();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('refuses a custom BLOCK with a designer band and nothing live', () => {
|
|
255
|
+
const warn = jest
|
|
256
|
+
.spyOn(console, 'warn')
|
|
257
|
+
.mockImplementation(() => undefined);
|
|
258
|
+
|
|
259
|
+
// A block's children are its slot nodes and its merchant content; a region
|
|
260
|
+
// resolved against them would either find nothing or match a child that
|
|
261
|
+
// happens to carry the reserved property.
|
|
262
|
+
const designer = renderRegion(
|
|
263
|
+
contextValue({
|
|
264
|
+
scope: 'block',
|
|
265
|
+
blocks: [regionBlock('search')],
|
|
266
|
+
isDesigner: true
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(markers(designer.container)).toHaveLength(0);
|
|
271
|
+
expect(bands(designer.container)).toHaveLength(1);
|
|
272
|
+
expect(designer.container.textContent).toContain(
|
|
273
|
+
'only works inside a custom section'
|
|
274
|
+
);
|
|
275
|
+
// The message has to name the thing the developer actually wanted: a
|
|
276
|
+
// merchant-fillable opening inside a block is a slot.
|
|
277
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('<ThemeSlot />'));
|
|
278
|
+
|
|
279
|
+
const live = renderRegion(
|
|
280
|
+
contextValue({ scope: 'block', blocks: [regionBlock('search')] })
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(live.container).toBeEmptyDOMElement();
|
|
284
|
+
|
|
285
|
+
warn.mockRestore();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('refuses a name the editor could never have seeded', () => {
|
|
289
|
+
const warn = jest
|
|
290
|
+
.spyOn(console, 'warn')
|
|
291
|
+
.mockImplementation(() => undefined);
|
|
292
|
+
|
|
293
|
+
const { container } = renderRegion(
|
|
294
|
+
contextValue({ blocks: [regionBlock('search')] }),
|
|
295
|
+
<ThemeRegion name="Store Search" />
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
expect(container).toBeEmptyDOMElement();
|
|
299
|
+
expect(warn).toHaveBeenCalledWith(
|
|
300
|
+
expect.stringContaining('"Store Search" is not valid')
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
warn.mockRestore();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('warns on a name the section never declared, naming the ones it did', () => {
|
|
307
|
+
const warn = jest
|
|
308
|
+
.spyOn(console, 'warn')
|
|
309
|
+
.mockImplementation(() => undefined);
|
|
310
|
+
|
|
311
|
+
// A typo and a merchant deletion look identical on screen — both are an
|
|
312
|
+
// empty band. Only the declaration tells them apart, and a developer
|
|
313
|
+
// staring at the band has nothing else to go on.
|
|
314
|
+
renderRegion(
|
|
315
|
+
contextValue({
|
|
316
|
+
blocks: [regionBlock('search')],
|
|
317
|
+
declaredRegionNames: ['search', 'basket']
|
|
318
|
+
}),
|
|
319
|
+
<ThemeRegion name="baskett" />
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(warn).toHaveBeenCalledWith(
|
|
323
|
+
expect.stringContaining('places a region "baskett" it never declared')
|
|
324
|
+
);
|
|
325
|
+
expect(warn).toHaveBeenCalledWith(
|
|
326
|
+
expect.stringContaining('Declared: search, basket')
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
warn.mockRestore();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -55,8 +55,8 @@ const contextValue = ({
|
|
|
55
55
|
isDesigner?: boolean;
|
|
56
56
|
slotSink?: (descriptor: CustomSlotDescriptor) => void;
|
|
57
57
|
declaredSlotNames?: string[];
|
|
58
|
-
} = {}): CustomBlockContextValue =>
|
|
59
|
-
block
|
|
58
|
+
} = {}): CustomBlockContextValue => {
|
|
59
|
+
const block = {
|
|
60
60
|
id: 'b1',
|
|
61
61
|
type: 'custom:acme:promo-strip',
|
|
62
62
|
label: 'Promo strip',
|
|
@@ -65,14 +65,22 @@ const contextValue = ({
|
|
|
65
65
|
order: 0,
|
|
66
66
|
hidden: false,
|
|
67
67
|
blocks
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// `node` is what the helpers read and `block` is the compatibility alias;
|
|
71
|
+
// they are the SAME object here, which is what a block host provides.
|
|
72
|
+
return {
|
|
73
|
+
node: block,
|
|
74
|
+
scope: 'block',
|
|
75
|
+
block,
|
|
76
|
+
placeholderId: 'p',
|
|
77
|
+
sectionId: 's',
|
|
78
|
+
isDesigner,
|
|
79
|
+
currentBreakpoint: 'desktop',
|
|
80
|
+
...(declaredSlotNames ? { declaredSlotNames } : {}),
|
|
81
|
+
...(slotSink ? { collect: { setting: jest.fn(), slot: slotSink } } : {})
|
|
82
|
+
};
|
|
83
|
+
};
|
|
76
84
|
|
|
77
85
|
const renderSlot = (
|
|
78
86
|
value: CustomBlockContextValue | null,
|
|
@@ -17,8 +17,8 @@ const contextValue = ({
|
|
|
17
17
|
properties?: Record<string, unknown>;
|
|
18
18
|
currentBreakpoint?: string;
|
|
19
19
|
sink?: SettingSink;
|
|
20
|
-
} = {}): CustomBlockContextValue =>
|
|
21
|
-
block
|
|
20
|
+
} = {}): CustomBlockContextValue => {
|
|
21
|
+
const block = {
|
|
22
22
|
id: 'b1',
|
|
23
23
|
type: 'custom:acme:promo-strip',
|
|
24
24
|
label: 'Promo strip',
|
|
@@ -26,13 +26,21 @@ const contextValue = ({
|
|
|
26
26
|
styles: {},
|
|
27
27
|
order: 0,
|
|
28
28
|
hidden: false
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// `node` is what the helpers read and `block` is the compatibility alias;
|
|
32
|
+
// they are the SAME object here, which is what a block host provides.
|
|
33
|
+
return {
|
|
34
|
+
node: block,
|
|
35
|
+
scope: 'block',
|
|
36
|
+
block,
|
|
37
|
+
placeholderId: 'p',
|
|
38
|
+
sectionId: 's',
|
|
39
|
+
isDesigner: false,
|
|
40
|
+
currentBreakpoint,
|
|
41
|
+
...(sink ? { collect: { setting: sink, slot: jest.fn() } } : {})
|
|
42
|
+
};
|
|
43
|
+
};
|
|
36
44
|
|
|
37
45
|
const wrapperFor = (value: CustomBlockContextValue | null) => {
|
|
38
46
|
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
@@ -2,13 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext } from 'react';
|
|
4
4
|
import type { Block } from '../theme-block';
|
|
5
|
+
import type { Section } from '../theme-section';
|
|
5
6
|
import type {
|
|
6
7
|
CustomSettingDescriptor,
|
|
7
8
|
CustomSlotDescriptor
|
|
8
9
|
} from './types';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
+
* The composition node a custom unit renders from.
|
|
13
|
+
*
|
|
14
|
+
* A `Block` for a custom block, the `Section` itself for a custom section. The
|
|
15
|
+
* two shapes agree on everything the authoring helpers read — `properties` for
|
|
16
|
+
* settings and fields, `blocks` for slots and regions — which is why one
|
|
17
|
+
* context serves both instead of a second one that would have to be threaded
|
|
18
|
+
* through `ThemeSlot`, `ThemeEditable` and `useThemeSetting` in parallel.
|
|
19
|
+
*/
|
|
20
|
+
export type CustomNode = Block | Section;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What `ThemeEditable`, `useThemeSetting`, `ThemeSlot` and `ThemeRegion` read.
|
|
12
24
|
*
|
|
13
25
|
* A context rather than props, because the whole point of the authoring model
|
|
14
26
|
* is that a developer writes ordinary JSX. Threading `block`/`breakpoint`
|
|
@@ -16,6 +28,31 @@ import type {
|
|
|
16
28
|
* down would be the tax this API exists to remove.
|
|
17
29
|
*/
|
|
18
30
|
export interface CustomBlockContextValue {
|
|
31
|
+
/**
|
|
32
|
+
* The node this unit renders. READ THIS, not `block` below.
|
|
33
|
+
*
|
|
34
|
+
* For a custom block it is the block; for a custom section it is the section,
|
|
35
|
+
* whose `properties` hold the section's settings and whose `blocks` hold the
|
|
36
|
+
* seeded regions.
|
|
37
|
+
*/
|
|
38
|
+
node: CustomNode;
|
|
39
|
+
/**
|
|
40
|
+
* Which kind of unit is rendering.
|
|
41
|
+
*
|
|
42
|
+
* `ThemeRegion` refuses to render outside a section, because a region resolves
|
|
43
|
+
* against `section.blocks` — inside a block it would either find nothing or,
|
|
44
|
+
* worse, match a child that happens to carry the reserved property.
|
|
45
|
+
*/
|
|
46
|
+
scope: 'block' | 'section';
|
|
47
|
+
/**
|
|
48
|
+
* The same object as `node`, kept because brand code already reads it.
|
|
49
|
+
*
|
|
50
|
+
* A `Section` satisfies `Block` structurally (it carries every required
|
|
51
|
+
* field), so this stays type-correct for a section host too. New code should
|
|
52
|
+
* read `node`: the name is honest there, and a reader that reaches for a
|
|
53
|
+
* Block-only field like `value` or `isIterator` is then visibly wrong instead
|
|
54
|
+
* of quietly undefined.
|
|
55
|
+
*/
|
|
19
56
|
block: Block;
|
|
20
57
|
placeholderId: string;
|
|
21
58
|
sectionId: string;
|
|
@@ -23,9 +60,9 @@ export interface CustomBlockContextValue {
|
|
|
23
60
|
currentBreakpoint: string;
|
|
24
61
|
selectedBlockId?: string | null;
|
|
25
62
|
/**
|
|
26
|
-
* The slot names `defineCustomBlock` DECLARED, in
|
|
27
|
-
* bare `<ThemeSlot />` can resolve to the
|
|
28
|
-
* called.
|
|
63
|
+
* The slot names `defineCustomBlock`/`defineCustomSection` DECLARED, in
|
|
64
|
+
* declaration order, so a bare `<ThemeSlot />` can resolve to the unit's
|
|
65
|
+
* single slot whatever it is called.
|
|
29
66
|
*
|
|
30
67
|
* From the definition and not from the catalog descriptor, which is the
|
|
31
68
|
* whole reason it is threaded here. The descriptor's `slots` array is
|
|
@@ -36,7 +73,13 @@ export interface CustomBlockContextValue {
|
|
|
36
73
|
*/
|
|
37
74
|
declaredSlotNames?: readonly string[];
|
|
38
75
|
/**
|
|
39
|
-
*
|
|
76
|
+
* The region names the section DECLARED. `ThemeRegion` uses it for one thing
|
|
77
|
+
* only: telling a merchant's deletion (ordinary, durable, silent) apart from
|
|
78
|
+
* a developer typo (a name nothing was ever seeded for, worth a warning).
|
|
79
|
+
*/
|
|
80
|
+
declaredRegionNames?: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Runtime collection sink. Present only while the unit is rendering inside
|
|
40
83
|
* a host that is collecting — see `collect.ts`. The build-time extractor is
|
|
41
84
|
* the authoritative source of the descriptor; this is the fallback that
|
|
42
85
|
* makes the API work before the extractor exists, and the cross-check that
|
|
@@ -52,12 +95,12 @@ export const CustomBlockContext =
|
|
|
52
95
|
createContext<CustomBlockContextValue | null>(null);
|
|
53
96
|
|
|
54
97
|
/**
|
|
55
|
-
* Returns null outside a custom block rather than throwing.
|
|
98
|
+
* Returns null outside a custom block or section rather than throwing.
|
|
56
99
|
*
|
|
57
|
-
* The
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
100
|
+
* The public components have to say something useful when a developer uses
|
|
101
|
+
* them in the wrong place — a plain React tree, a built-in block, a unit test.
|
|
102
|
+
* Each handles the null itself with a named message; a bare throw here would
|
|
103
|
+
* surface as a stack trace pointing at this file rather than at the
|
|
61
104
|
* `<ThemeEditable>` the developer actually mis-placed.
|
|
62
105
|
*/
|
|
63
106
|
export const useCustomBlockContext = (): CustomBlockContextValue | null =>
|
|
@@ -29,8 +29,15 @@ interface CustomBlockErrorBoundaryProps {
|
|
|
29
29
|
* gets a labelled box instead, because a block that silently disappears from
|
|
30
30
|
* the canvas reads as "the editor lost my block" rather than "this component
|
|
31
31
|
* threw" — and the merchant still needs something to select and delete.
|
|
32
|
+
*
|
|
33
|
+
* Exported because `section-host.tsx` uses the SAME boundary rather than a
|
|
34
|
+
* second copy. A section host has no lower level to fall back to, so the
|
|
35
|
+
* boundary buys it a labelled designer card rather than blast-radius reduction
|
|
36
|
+
* — but a diverged copy would be a second place for the live/designer split to
|
|
37
|
+
* be got wrong, and getting it wrong live means shipping a broken card to
|
|
38
|
+
* shoppers.
|
|
32
39
|
*/
|
|
33
|
-
class CustomBlockErrorBoundary extends React.Component<
|
|
40
|
+
export class CustomBlockErrorBoundary extends React.Component<
|
|
34
41
|
CustomBlockErrorBoundaryProps,
|
|
35
42
|
{ hasError: boolean }
|
|
36
43
|
> {
|
|
@@ -122,6 +129,11 @@ export const CustomBlockHost = ({
|
|
|
122
129
|
|
|
123
130
|
const value = useMemo<CustomBlockContextValue>(
|
|
124
131
|
() => ({
|
|
132
|
+
// `node` and `block` are the same object here. The pair exists so a
|
|
133
|
+
// section host can fill `node` with the section while brand code that
|
|
134
|
+
// already reads `block` keeps working — see `block-context.ts`.
|
|
135
|
+
node: block,
|
|
136
|
+
scope: 'block',
|
|
125
137
|
block,
|
|
126
138
|
placeholderId,
|
|
127
139
|
sectionId,
|
package/src/custom/collect.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { devWarn } from '../utils/dev-warn';
|
|
2
2
|
import { getCustomBlockDescriptor } from './catalog';
|
|
3
|
+
import { getCustomSectionDescriptor } from './section-catalog';
|
|
3
4
|
import { fieldPartName } from './editable-tags';
|
|
4
5
|
import { slotNameError } from './slot-nodes';
|
|
5
6
|
import {
|
|
@@ -141,6 +142,19 @@ export interface CustomCollectSink {
|
|
|
141
142
|
slot: (descriptor: CustomSlotDescriptor) => void;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/**
|
|
146
|
+
* What a sink writes into. Both `CustomBlockDescriptor` and
|
|
147
|
+
* `CustomSectionDescriptor` satisfy it, which is what lets one sink serve
|
|
148
|
+
* either — declared structurally rather than as a union so a third descriptor
|
|
149
|
+
* shape would not have to be added here as well.
|
|
150
|
+
*/
|
|
151
|
+
interface CollectTarget {
|
|
152
|
+
settings: CustomSettingDescriptor[];
|
|
153
|
+
slots: CustomSlotDescriptor[];
|
|
154
|
+
parts: string[];
|
|
155
|
+
hasChildren: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
const sinks = new Map<string, CustomCollectSink>();
|
|
145
159
|
const finished = new Set<string>();
|
|
146
160
|
|
|
@@ -187,15 +201,22 @@ export const getCollectSink = (type: string): CustomCollectSink => {
|
|
|
187
201
|
const existing = sinks.get(type);
|
|
188
202
|
if (existing) return existing;
|
|
189
203
|
|
|
190
|
-
|
|
191
|
-
|
|
204
|
+
// Blocks and sections share one type namespace — registration refuses a type
|
|
205
|
+
// already claimed by either — so one lookup order is unambiguous and a sink
|
|
206
|
+
// can serve both. The shapes agree on everything this file writes: settings,
|
|
207
|
+
// slots and parts. `hasChildren` is on both too, so the slot sink needs no
|
|
208
|
+
// branch of its own.
|
|
209
|
+
const targetDescriptor = (): CollectTarget | undefined => {
|
|
210
|
+
const descriptor: CollectTarget | undefined =
|
|
211
|
+
getCustomBlockDescriptor(type) ?? getCustomSectionDescriptor(type);
|
|
212
|
+
|
|
192
213
|
if (!descriptor) {
|
|
193
214
|
devWarn(
|
|
194
215
|
'customBlock',
|
|
195
216
|
`"${type}" rendered with no catalog descriptor, so nothing it uses can be collected. ` +
|
|
196
|
-
`
|
|
197
|
-
`barrel calling registerCustomBlocks() is imported from the
|
|
198
|
-
`webpack keeps serving the old copy of a changed package.`
|
|
217
|
+
`It registered into a different module copy than the one rendering it: check that the ` +
|
|
218
|
+
`barrel calling registerCustomBlocks()/registerCustomSections() is imported from the ` +
|
|
219
|
+
`CLIENT ROOT, and clear .next — webpack keeps serving the old copy of a changed package.`
|
|
199
220
|
);
|
|
200
221
|
}
|
|
201
222
|
return descriptor;
|