@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,481 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import blockRendererRegistry from '../../blocks/block-renderer-registry';
|
|
4
|
+
import { resetCustomCatalog } from '../catalog';
|
|
5
|
+
import { resetCustomCollection } from '../collect';
|
|
6
|
+
import { defineCustomBlock } from '../define';
|
|
7
|
+
import {
|
|
8
|
+
CUSTOM_SECTION_MARKER,
|
|
9
|
+
defineCustomSection,
|
|
10
|
+
isCustomSectionDefinition
|
|
11
|
+
} from '../define-section';
|
|
12
|
+
import { registerCustomBlocks, registerCustomSections } from '../register';
|
|
13
|
+
import {
|
|
14
|
+
getCustomSectionDescriptor,
|
|
15
|
+
getCustomSectionTypes,
|
|
16
|
+
resetCustomSectionCatalog
|
|
17
|
+
} from '../section-catalog';
|
|
18
|
+
import {
|
|
19
|
+
getCustomSectionDefinition,
|
|
20
|
+
hasCustomSectionDefinition,
|
|
21
|
+
resetCustomSectionDefinitions
|
|
22
|
+
} from '../section-definitions';
|
|
23
|
+
import { CUSTOM_SECTION_LIMITS } from '../section-types';
|
|
24
|
+
import { isCustomThemeType } from '../types';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Neither registry has an `unregister` — a build registers once and a
|
|
28
|
+
* storefront never takes a type back. A test file registers dozens of times
|
|
29
|
+
* into that same module state, so it reaches the private Map directly, exactly
|
|
30
|
+
* as define.test.tsx does.
|
|
31
|
+
*/
|
|
32
|
+
const rendererMap = (): Map<string, unknown> =>
|
|
33
|
+
(blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
|
|
34
|
+
.renderers;
|
|
35
|
+
|
|
36
|
+
const clearCustomRenderers = (): void => {
|
|
37
|
+
const map = rendererMap();
|
|
38
|
+
Array.from(map.keys())
|
|
39
|
+
.filter(isCustomThemeType)
|
|
40
|
+
.forEach((type) => map.delete(type));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const Header = () => <header>header</header>;
|
|
44
|
+
const ProductPage = () => <main>product</main>;
|
|
45
|
+
|
|
46
|
+
const HEADER = 'custom:acme:header';
|
|
47
|
+
|
|
48
|
+
const headerDescriptor = () => getCustomSectionDescriptor(HEADER);
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
resetCustomCatalog();
|
|
52
|
+
resetCustomCollection();
|
|
53
|
+
resetCustomSectionCatalog();
|
|
54
|
+
resetCustomSectionDefinitions();
|
|
55
|
+
clearCustomRenderers();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('defineCustomSection', () => {
|
|
59
|
+
it('tags the wrapper with a string marker, not a Symbol', () => {
|
|
60
|
+
const definition = defineCustomSection(Header, { label: 'Header' });
|
|
61
|
+
|
|
62
|
+
// A brand can resolve two physical copies of pz-theme at once. A
|
|
63
|
+
// `Symbol()` per copy would make one copy read the other's definition as a
|
|
64
|
+
// bare component and silently drop every option declared here.
|
|
65
|
+
expect(definition.$$pzCustomSection).toBe(CUSTOM_SECTION_MARKER);
|
|
66
|
+
expect(typeof CUSTOM_SECTION_MARKER).toBe('string');
|
|
67
|
+
expect(isCustomSectionDefinition(definition)).toBe(true);
|
|
68
|
+
expect(isCustomSectionDefinition(Header)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('accepts a component wrapped in memo, which is ordinary React', () => {
|
|
72
|
+
const definition = defineCustomSection(
|
|
73
|
+
React.memo(Header) as unknown as typeof Header
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(isCustomSectionDefinition(definition)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws on a value that could never render, naming what it received', () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
defineCustomSection(undefined as unknown as typeof Header)
|
|
82
|
+
).toThrow(/expects a React component, received undefined/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('refuses a record entry that is not a component at all', () => {
|
|
86
|
+
// The shape a missing default export arrives in: `{ header: undefined }`
|
|
87
|
+
// from a barrel whose file exports nothing. Named at the record key,
|
|
88
|
+
// because that is the only identifier the developer can search for.
|
|
89
|
+
expect(() =>
|
|
90
|
+
registerCustomSections('acme', {
|
|
91
|
+
header: undefined as unknown as typeof Header
|
|
92
|
+
})
|
|
93
|
+
).toThrow(/Custom section "header" is neither a React component/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('registerCustomSections — the offered-implies-renderable invariant', () => {
|
|
98
|
+
it('puts every registered section in BOTH stores, and nothing else in either', () => {
|
|
99
|
+
registerCustomSections('acme', {
|
|
100
|
+
header: defineCustomSection(Header, { label: 'ZSA header' }),
|
|
101
|
+
// A bare component: someone will forget the wrapper, and a section with
|
|
102
|
+
// no declared options is a complete section.
|
|
103
|
+
'product-page': ProductPage
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const expected = new Set([HEADER, 'custom:acme:product-page']);
|
|
107
|
+
|
|
108
|
+
expect(new Set(getCustomSectionTypes())).toEqual(expected);
|
|
109
|
+
expect(hasCustomSectionDefinition(HEADER)).toBe(true);
|
|
110
|
+
// A type in the catalog and not in the definitions is a section the editor
|
|
111
|
+
// offers and the storefront cannot draw; the reverse is one the merchant
|
|
112
|
+
// can never place. Both directions are checked.
|
|
113
|
+
getCustomSectionTypes().forEach((type) => {
|
|
114
|
+
expect(hasCustomSectionDefinition(type)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
expect(getCustomSectionDefinition('custom:acme:product-page')?.component).toBe(
|
|
117
|
+
ProductPage
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('registers a bare component under the record key, not the component name', () => {
|
|
122
|
+
// Minification renames `Header` to a single letter; the record key is the
|
|
123
|
+
// only source that survives a production build.
|
|
124
|
+
registerCustomSections('acme', { header: Header });
|
|
125
|
+
|
|
126
|
+
expect(headerDescriptor()?.name).toBe('header');
|
|
127
|
+
expect(headerDescriptor()?.label).toBe('header');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('registerCustomSections — one type namespace, two stores', () => {
|
|
132
|
+
it('refuses a type a custom BLOCK already claimed', () => {
|
|
133
|
+
// Blocks and sections share the `custom:<vendor>:<name>` grammar, so
|
|
134
|
+
// nothing downstream can tell them apart from the string alone: the editor
|
|
135
|
+
// resolves a section through its section catalog and a block through its
|
|
136
|
+
// block catalog, and a type in both resolves differently depending on
|
|
137
|
+
// which lookup ran.
|
|
138
|
+
registerCustomBlocks('acme', {
|
|
139
|
+
header: defineCustomBlock(Header, { label: 'Header block' })
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(() => registerCustomSections('acme', { header: Header })).toThrow(
|
|
143
|
+
/already registered in this build, as a block or as a section/
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('refuses a type a custom SECTION already claimed, from the BLOCK door', () => {
|
|
148
|
+
// The other direction, and it was one-way for a while: the block door
|
|
149
|
+
// checked only the block stores, so a section registered first and a block
|
|
150
|
+
// of the same type second was silently accepted — leaving one type present
|
|
151
|
+
// in all four stores, resolving as whichever the caller happened to ask.
|
|
152
|
+
// A namespace guarded in one direction is not a namespace.
|
|
153
|
+
registerCustomSections('acme', { header: Header });
|
|
154
|
+
|
|
155
|
+
expect(() =>
|
|
156
|
+
registerCustomBlocks('acme', {
|
|
157
|
+
header: defineCustomBlock(Header, { label: 'Header block' })
|
|
158
|
+
})
|
|
159
|
+
).toThrow(/already registered in this build, as a block or as a section/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('refuses a second registration of the same section type', () => {
|
|
163
|
+
registerCustomSections('acme', { header: Header });
|
|
164
|
+
|
|
165
|
+
expect(() =>
|
|
166
|
+
registerCustomSections('acme', { header: ProductPage })
|
|
167
|
+
).toThrow(/already registered/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('leaves the first entry unregistered in both stores when a later one is invalid', () => {
|
|
171
|
+
// A half-registered barrel is worse than a rejected one: the Add Section
|
|
172
|
+
// band would offer a section the build can draw and hide one it cannot.
|
|
173
|
+
expect(() =>
|
|
174
|
+
registerCustomSections('acme', {
|
|
175
|
+
header: Header,
|
|
176
|
+
'product-page': defineCustomSection(ProductPage, {
|
|
177
|
+
regions: [{ name: 'Gallery', block: 'image' }]
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
).toThrow(/"Gallery" is not valid/);
|
|
181
|
+
|
|
182
|
+
expect(getCustomSectionTypes()).toEqual([]);
|
|
183
|
+
expect(hasCustomSectionDefinition(HEADER)).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('registerCustomSections — naming', () => {
|
|
188
|
+
it('throws on a vendor that is not a valid segment', () => {
|
|
189
|
+
expect(() => registerCustomSections('Acme Inc', { header: Header })).toThrow(
|
|
190
|
+
/vendor "Acme Inc"/
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('throws on a section name that is not a valid segment', () => {
|
|
195
|
+
expect(() => registerCustomSections('acme', { Header_Bar: Header })).toThrow(
|
|
196
|
+
/"Header_Bar"/
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('registerCustomSections — declared regions', () => {
|
|
202
|
+
it('carries the name, the block type and the seed through untouched', () => {
|
|
203
|
+
registerCustomSections('acme', {
|
|
204
|
+
header: defineCustomSection(Header, {
|
|
205
|
+
regions: [
|
|
206
|
+
{ name: 'logo', label: 'Logo', block: 'image' },
|
|
207
|
+
{
|
|
208
|
+
name: 'basket',
|
|
209
|
+
block: 'mini-basket',
|
|
210
|
+
properties: { showCount: { desktop: true } },
|
|
211
|
+
styles: { 'margin-left': '16px' }
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
})
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// The name is what `<ThemeRegion name>` matches against the seeded block,
|
|
218
|
+
// and the seed is written straight into the composition, so both have to
|
|
219
|
+
// survive registration verbatim.
|
|
220
|
+
expect(headerDescriptor()?.regions).toEqual([
|
|
221
|
+
{ name: 'logo', label: 'Logo', block: 'image' },
|
|
222
|
+
{
|
|
223
|
+
name: 'basket',
|
|
224
|
+
block: 'mini-basket',
|
|
225
|
+
properties: { showCount: { desktop: true } },
|
|
226
|
+
styles: { 'margin-left': '16px' }
|
|
227
|
+
}
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('throws on a name no seeded block could be matched by', () => {
|
|
232
|
+
expect(() =>
|
|
233
|
+
registerCustomSections('acme', {
|
|
234
|
+
header: defineCustomSection(Header, {
|
|
235
|
+
regions: [{ name: 'Store Search', block: 'search-box' }]
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
).toThrow(/"Store Search" is not valid/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('throws naming a region declared twice', () => {
|
|
242
|
+
expect(() =>
|
|
243
|
+
registerCustomSections('acme', {
|
|
244
|
+
header: defineCustomSection(Header, {
|
|
245
|
+
regions: [
|
|
246
|
+
{ name: 'logo', block: 'image' },
|
|
247
|
+
{ name: 'logo', block: 'text' }
|
|
248
|
+
]
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
).toThrow(/declares the region "logo" twice/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('throws on a region with no block type — the merchant would see nothing to open', () => {
|
|
255
|
+
expect(() =>
|
|
256
|
+
registerCustomSections('acme', {
|
|
257
|
+
header: defineCustomSection(Header, {
|
|
258
|
+
regions: [{ name: 'logo', block: '' }]
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
).toThrow(/with no block type/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it.each(['properties', 'styles'])(
|
|
265
|
+
'throws on a %s seed that is not the shape the composition stores',
|
|
266
|
+
(field) => {
|
|
267
|
+
expect(() =>
|
|
268
|
+
registerCustomSections('acme', {
|
|
269
|
+
header: defineCustomSection(Header, {
|
|
270
|
+
regions: [
|
|
271
|
+
{
|
|
272
|
+
name: 'logo',
|
|
273
|
+
block: 'image',
|
|
274
|
+
[field]: ['margin-left', '16px']
|
|
275
|
+
} as never
|
|
276
|
+
]
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
).toThrow(/is not a plain object/);
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
it('throws when one name is declared as both a region and a slot', () => {
|
|
284
|
+
// Two rows with one name in the layer tree. The resolvers never collide —
|
|
285
|
+
// they read different properties — but the merchant has no way to tell
|
|
286
|
+
// which row is which.
|
|
287
|
+
expect(() =>
|
|
288
|
+
registerCustomSections('acme', {
|
|
289
|
+
header: defineCustomSection(Header, {
|
|
290
|
+
regions: [{ name: 'extras', block: 'image' }],
|
|
291
|
+
slots: [{ name: 'extras' }]
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
).toThrow(/as both a region and a slot/);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('registerCustomSections — caps', () => {
|
|
299
|
+
const many = <T,>(count: number, build: (index: number) => T): T[] =>
|
|
300
|
+
Array.from({ length: count }, (_, index) => build(index));
|
|
301
|
+
|
|
302
|
+
it('refuses more regions than the editor would seed', () => {
|
|
303
|
+
expect(() =>
|
|
304
|
+
registerCustomSections('acme', {
|
|
305
|
+
header: defineCustomSection(Header, {
|
|
306
|
+
regions: many(CUSTOM_SECTION_LIMITS.regionsPerType + 1, (index) => ({
|
|
307
|
+
name: `r${index}`,
|
|
308
|
+
block: 'image'
|
|
309
|
+
}))
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
).toThrow(/regions, over the 24 cap/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('refuses more slots than the announce should carry', () => {
|
|
316
|
+
expect(() =>
|
|
317
|
+
registerCustomSections('acme', {
|
|
318
|
+
header: defineCustomSection(Header, {
|
|
319
|
+
slots: many(CUSTOM_SECTION_LIMITS.slotsPerType + 1, (index) => ({
|
|
320
|
+
name: `s${index}`
|
|
321
|
+
}))
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
).toThrow(/slots, over the 8 cap/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('refuses more settings than the panel should carry', () => {
|
|
328
|
+
expect(() =>
|
|
329
|
+
registerCustomSections('acme', {
|
|
330
|
+
header: defineCustomSection(Header, {
|
|
331
|
+
settings: many(
|
|
332
|
+
CUSTOM_SECTION_LIMITS.settingsPerType + 1,
|
|
333
|
+
(index) => ({ key: `k${index}`, type: 'text' as const })
|
|
334
|
+
)
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
).toThrow(/settings, over the 24 cap/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('refuses more parts than the editor would build control groups for', () => {
|
|
341
|
+
expect(() =>
|
|
342
|
+
registerCustomSections('acme', {
|
|
343
|
+
header: defineCustomSection(Header, {
|
|
344
|
+
parts: many(CUSTOM_SECTION_LIMITS.partsPerType + 1, (index) =>
|
|
345
|
+
`p${index}`
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
).toThrow(/parts, over the 32 cap/);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('refuses a build declaring more sections than the announce should carry', () => {
|
|
353
|
+
const record: Record<string, typeof Header> = {};
|
|
354
|
+
many(CUSTOM_SECTION_LIMITS.sectionTypesPerBuild + 1, (index) => index).forEach(
|
|
355
|
+
(index) => {
|
|
356
|
+
record[`section-${index}`] = Header;
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
expect(() => registerCustomSections('acme', record)).toThrow(
|
|
361
|
+
/would declare 17 custom sections, over the 16 cap/
|
|
362
|
+
);
|
|
363
|
+
// Refused whole: the cap is enforced before anything is written, so the
|
|
364
|
+
// build does not end up with 16 of the 17 registered.
|
|
365
|
+
expect(getCustomSectionTypes()).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('registerCustomSections — the descriptor on the wire', () => {
|
|
370
|
+
it('survives its own JSON round trip unchanged', () => {
|
|
371
|
+
registerCustomSections('acme', {
|
|
372
|
+
header: defineCustomSection(Header, {
|
|
373
|
+
label: 'ZSA header',
|
|
374
|
+
icon: 'layout',
|
|
375
|
+
group: 'Chrome',
|
|
376
|
+
settings: [
|
|
377
|
+
{
|
|
378
|
+
key: 'tone',
|
|
379
|
+
type: 'select',
|
|
380
|
+
label: 'Tone',
|
|
381
|
+
options: ['light', 'dark'],
|
|
382
|
+
defaultValue: 'light'
|
|
383
|
+
}
|
|
384
|
+
],
|
|
385
|
+
regions: [{ name: 'logo', label: 'Logo', block: 'image' }],
|
|
386
|
+
slots: [{ name: 'extras', label: 'Extra header actions' }],
|
|
387
|
+
parts: { bar: { states: ['hover'] } },
|
|
388
|
+
styles: { 'padding-top': '12px' },
|
|
389
|
+
placeholders: ['header'],
|
|
390
|
+
lockRootTransforms: true
|
|
391
|
+
})
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const descriptor = headerDescriptor();
|
|
395
|
+
|
|
396
|
+
expect(descriptor).toEqual(JSON.parse(JSON.stringify(descriptor)));
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('keeps the three section-only fields, which nothing else carries', () => {
|
|
400
|
+
registerCustomSections('acme', {
|
|
401
|
+
header: defineCustomSection(Header, {
|
|
402
|
+
styles: { 'padding-top': '12px' },
|
|
403
|
+
placeholders: ['header', 'header-mobile'],
|
|
404
|
+
lockRootTransforms: true
|
|
405
|
+
})
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const descriptor = headerDescriptor();
|
|
409
|
+
|
|
410
|
+
// `lockRootTransforms` is the one that is not tidiness: rotate, scale,
|
|
411
|
+
// translate, filter and backdrop-filter each create a containing block for
|
|
412
|
+
// a `position: fixed` descendant, so one of them on the root collapses a
|
|
413
|
+
// fixed header into the root's own box — measured at 1247px becoming
|
|
414
|
+
// 600px. Losing this field in transit puts the trap back on the panel.
|
|
415
|
+
expect(descriptor?.lockRootTransforms).toBe(true);
|
|
416
|
+
expect(descriptor?.placeholders).toEqual(['header', 'header-mobile']);
|
|
417
|
+
expect(descriptor?.styles).toEqual({ 'padding-top': '12px' });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('says hasChildren even for a section that declares neither region nor slot', () => {
|
|
421
|
+
// Unlike a block, which earns it by declaring a slot. A section always
|
|
422
|
+
// hosts children, and the editor reads the same field off its own registry
|
|
423
|
+
// entry — a false here makes the section undroppable.
|
|
424
|
+
registerCustomSections('acme', { header: Header });
|
|
425
|
+
|
|
426
|
+
expect(headerDescriptor()?.hasChildren).toBe(true);
|
|
427
|
+
expect(headerDescriptor()?.regions).toEqual([]);
|
|
428
|
+
expect(headerDescriptor()?.slots).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('omits an undeclared optional field rather than sending it undefined', () => {
|
|
432
|
+
registerCustomSections('acme', { header: Header });
|
|
433
|
+
|
|
434
|
+
// Through `unknown`: a descriptor is an interface with no index
|
|
435
|
+
// signature, and jest never type-checks this file — the only tsc that
|
|
436
|
+
// reaches it is the template app's, where the direct cast is an error.
|
|
437
|
+
const descriptor = headerDescriptor() as unknown as Record<
|
|
438
|
+
string,
|
|
439
|
+
unknown
|
|
440
|
+
>;
|
|
441
|
+
|
|
442
|
+
// `undefined` does not survive postMessage, and a descriptor carrying the
|
|
443
|
+
// key would diverge from its own round trip — which is what
|
|
444
|
+
// `assertSerializable` refuses.
|
|
445
|
+
expect('icon' in descriptor).toBe(false);
|
|
446
|
+
expect('placeholders' in descriptor).toBe(false);
|
|
447
|
+
expect('lockRootTransforms' in descriptor).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('throws naming the path of a value JSON would drop', () => {
|
|
451
|
+
expect(() =>
|
|
452
|
+
registerCustomSections('acme', {
|
|
453
|
+
header: defineCustomSection(Header, {
|
|
454
|
+
regions: [
|
|
455
|
+
{
|
|
456
|
+
name: 'logo',
|
|
457
|
+
block: 'image',
|
|
458
|
+
// A function cannot cross postMessage; without the mechanical
|
|
459
|
+
// catch the editor would seed this region with no styles and
|
|
460
|
+
// nothing would report it.
|
|
461
|
+
styles: { 'margin-left': () => '16px' }
|
|
462
|
+
}
|
|
463
|
+
]
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
).toThrow(/does not survive JSON/);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('joins the declared parts to the field-derived ones in one list', () => {
|
|
470
|
+
registerCustomSections('acme', {
|
|
471
|
+
header: defineCustomSection(Header, {
|
|
472
|
+
settings: [
|
|
473
|
+
{ key: 'tagline', type: 'text', label: 'Tagline', editable: true }
|
|
474
|
+
],
|
|
475
|
+
parts: ['bar', 'row']
|
|
476
|
+
})
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
expect(headerDescriptor()?.parts).toEqual(['field-tagline', 'bar', 'row']);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -18,6 +18,8 @@ import blockRendererRegistry from '../../blocks/block-renderer-registry';
|
|
|
18
18
|
import type { BlockRendererProps } from '../../blocks/block-renderer-registry';
|
|
19
19
|
import { resetCustomCatalog } from '../catalog';
|
|
20
20
|
import { resetCustomCollection } from '../collect';
|
|
21
|
+
import { resetCustomSectionCatalog } from '../section-catalog';
|
|
22
|
+
import { resetCustomSectionDefinitions } from '../section-definitions';
|
|
21
23
|
|
|
22
24
|
// Through the barrel, never the individual modules. That is the point of this
|
|
23
25
|
// file: the three pieces each have their own suite, and every one of them
|
|
@@ -25,15 +27,24 @@ import { resetCustomCollection } from '../collect';
|
|
|
25
27
|
// still leave 86 green tests behind — while `@akinon/pz-theme/src/custom`, the
|
|
26
28
|
// path a brand actually types, resolved to nothing.
|
|
27
29
|
import {
|
|
30
|
+
CUSTOM_SECTION_LIMITS,
|
|
28
31
|
CUSTOM_SLOT_BLOCK_TYPE,
|
|
29
32
|
CUSTOM_TYPE_PREFIX,
|
|
30
33
|
defineCustomBlock,
|
|
34
|
+
defineCustomSection,
|
|
35
|
+
findRegionChild,
|
|
31
36
|
getCustomCatalog,
|
|
37
|
+
getCustomSectionCatalog,
|
|
32
38
|
isCustomThemeType,
|
|
39
|
+
isRegionNode,
|
|
33
40
|
partAttrs,
|
|
41
|
+
REGION_NAME_PROPERTY,
|
|
42
|
+
regionNodeName,
|
|
34
43
|
registerCustomBlocks,
|
|
44
|
+
registerCustomSections,
|
|
35
45
|
SLOT_NAME_PROPERTY,
|
|
36
46
|
ThemeEditable,
|
|
47
|
+
ThemeRegion,
|
|
37
48
|
ThemeSlot,
|
|
38
49
|
useThemeSetting
|
|
39
50
|
} from '../index';
|
|
@@ -187,6 +198,8 @@ afterEach(() => {
|
|
|
187
198
|
clearCustomRenderers();
|
|
188
199
|
resetCustomCatalog();
|
|
189
200
|
resetCustomCollection();
|
|
201
|
+
resetCustomSectionCatalog();
|
|
202
|
+
resetCustomSectionDefinitions();
|
|
190
203
|
});
|
|
191
204
|
|
|
192
205
|
describe('the documented barrel', () => {
|
|
@@ -361,6 +374,116 @@ describe('the advertised parts and the rendered DOM', () => {
|
|
|
361
374
|
});
|
|
362
375
|
});
|
|
363
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The SECTION half of the same barrel. A brand writing a header imports
|
|
379
|
+
* `defineCustomSection`, `ThemeRegion` and `registerCustomSections` from this
|
|
380
|
+
* one path, and every one of them is a name the root re-exports by hand — a
|
|
381
|
+
* renamed symbol compiles here and fails at the brand.
|
|
382
|
+
*/
|
|
383
|
+
describe('the section authoring surface', () => {
|
|
384
|
+
const HEADER = 'custom:acme:header';
|
|
385
|
+
|
|
386
|
+
/** The barrel's own documented example, unaltered. */
|
|
387
|
+
function ZsaHeader() {
|
|
388
|
+
return (
|
|
389
|
+
<header {...partAttrs('bar')}>
|
|
390
|
+
<ThemeRegion name="logo" />
|
|
391
|
+
<ThemeRegion name="basket" />
|
|
392
|
+
<ThemeSlot name="extras" label="Extra header actions" />
|
|
393
|
+
</header>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const registerHeader = () =>
|
|
398
|
+
registerCustomSections('acme', {
|
|
399
|
+
header: defineCustomSection(ZsaHeader, {
|
|
400
|
+
label: 'ZSA ZSA ZSU Header',
|
|
401
|
+
regions: [
|
|
402
|
+
{ name: 'logo', label: 'Logo', block: 'image' },
|
|
403
|
+
{ name: 'basket', label: 'Basket', block: 'mini-basket' }
|
|
404
|
+
],
|
|
405
|
+
slots: [{ name: 'extras', label: 'Extra header actions' }],
|
|
406
|
+
parts: ['bar'],
|
|
407
|
+
placeholders: ['header'],
|
|
408
|
+
lockRootTransforms: true
|
|
409
|
+
})
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('describes a registered section through the same barrel that registered it', () => {
|
|
413
|
+
registerHeader();
|
|
414
|
+
|
|
415
|
+
const catalog = getCustomSectionCatalog('acme');
|
|
416
|
+
const descriptor = catalog?.sections.find(
|
|
417
|
+
(entry) => entry.type === HEADER
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
expect(catalog?.vendor).toBe('acme');
|
|
421
|
+
// A region is a child the developer NAMED, chose the type of and PLACED —
|
|
422
|
+
// so the block type is part of the description, and it is what buys the
|
|
423
|
+
// merchant that block's own panel.
|
|
424
|
+
expect(descriptor?.regions).toEqual([
|
|
425
|
+
{ name: 'logo', label: 'Logo', block: 'image' },
|
|
426
|
+
{ name: 'basket', label: 'Basket', block: 'mini-basket' }
|
|
427
|
+
]);
|
|
428
|
+
expect(descriptor?.slots.map((slot) => slot.name)).toEqual(['extras']);
|
|
429
|
+
expect(descriptor?.parts).toEqual(['bar']);
|
|
430
|
+
expect(descriptor?.placeholders).toEqual(['header']);
|
|
431
|
+
expect(descriptor?.lockRootTransforms).toBe(true);
|
|
432
|
+
expect(descriptor?.hasChildren).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('rides beside the block catalog rather than inside it', () => {
|
|
436
|
+
registerCustomSections('acme', { header: ZsaHeader });
|
|
437
|
+
|
|
438
|
+
// Two payloads, two byte budgets. The editor validates the BLOCK catalog
|
|
439
|
+
// element by element and drops the whole thing on an unexpected shape, so
|
|
440
|
+
// nesting sections inside it would make every block in a build vanish from
|
|
441
|
+
// the palette of any editor older than this change.
|
|
442
|
+
expect(getCustomSectionCatalog('acme')?.sections).toHaveLength(1);
|
|
443
|
+
expect(getCustomCatalog()?.blocks.map((block) => block.type)).toEqual(
|
|
444
|
+
expect.arrayContaining([PROMO])
|
|
445
|
+
);
|
|
446
|
+
expect(
|
|
447
|
+
getCustomCatalog()?.blocks.some((block) => block.type === HEADER)
|
|
448
|
+
).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('reads a seeded region back through the grammar it exports', () => {
|
|
452
|
+
// The editor mirrors `REGION_NAME_PROPERTY` and seeds the block; this
|
|
453
|
+
// package reads it back. Both sides are exported from here so the mirror
|
|
454
|
+
// can be diffed against one constant instead of a literal re-typed
|
|
455
|
+
// slightly wrong.
|
|
456
|
+
const seeded = {
|
|
457
|
+
id: 'block-h3n1a',
|
|
458
|
+
type: 'image',
|
|
459
|
+
label: 'Logo',
|
|
460
|
+
properties: { [REGION_NAME_PROPERTY]: 'logo' },
|
|
461
|
+
styles: {},
|
|
462
|
+
order: 0,
|
|
463
|
+
hidden: false
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
expect(REGION_NAME_PROPERTY).toBe('regionName');
|
|
467
|
+
expect(regionNodeName(seeded)).toBe('logo');
|
|
468
|
+
expect(isRegionNode(seeded)).toBe(true);
|
|
469
|
+
expect(findRegionChild({ blocks: [seeded] }, 'logo')).toBe(seeded);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('publishes the section caps the editor mirrors as literals', () => {
|
|
473
|
+
// Every one is mirrored independently on the far side, and the receiver's
|
|
474
|
+
// answer to an over-cap catalog is to drop all of it — so a number moving
|
|
475
|
+
// on one side alone empties a brand's whole section band.
|
|
476
|
+
expect(CUSTOM_SECTION_LIMITS).toEqual({
|
|
477
|
+
sectionTypesPerBuild: 16,
|
|
478
|
+
settingsPerType: 24,
|
|
479
|
+
regionsPerType: 24,
|
|
480
|
+
slotsPerType: 8,
|
|
481
|
+
partsPerType: 32,
|
|
482
|
+
catalogBytes: 256 * 1024
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
364
487
|
describe('the curated package root', () => {
|
|
365
488
|
/**
|
|
366
489
|
* Parsed from source rather than imported, exactly as
|