@akinon/pz-theme 2.0.71-rc.0 → 2.0.71
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 -31
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +276 -0
- package/src/blocks/group-block.tsx +9 -57
- package/src/blocks/render-composed-children.tsx +104 -0
- package/src/custom/README.md +249 -0
- package/src/custom/__tests__/define.test.tsx +619 -0
- package/src/custom/__tests__/module-boundaries.test.ts +156 -0
- package/src/custom/__tests__/public-api.test.tsx +427 -0
- package/src/custom/__tests__/slot-node-wire.test.tsx +309 -0
- package/src/custom/__tests__/slot-nodes.test.ts +144 -0
- package/src/custom/__tests__/theme-editable.test.tsx +486 -0
- package/src/custom/__tests__/theme-slot.test.tsx +387 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +260 -0
- package/src/custom/block-context.ts +64 -0
- package/src/custom/block-host.tsx +162 -0
- package/src/custom/catalog.ts +97 -0
- package/src/custom/collect.ts +342 -0
- package/src/custom/define.tsx +172 -0
- package/src/custom/editable-tags.ts +120 -0
- package/src/custom/index.ts +105 -0
- package/src/custom/parts.ts +108 -0
- package/src/custom/property-types.ts +70 -0
- package/src/custom/register.ts +545 -0
- package/src/custom/slot-nodes.ts +119 -0
- package/src/custom/theme-editable.tsx +342 -0
- package/src/custom/theme-slot.tsx +210 -0
- package/src/custom/types.ts +251 -0
- package/src/custom/use-theme-setting.ts +225 -0
- package/src/index.ts +66 -0
- package/src/placeholder-registry.ts +168 -16
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import blockRendererRegistry from '../../blocks/block-renderer-registry';
|
|
5
|
+
import type { BlockRendererProps } from '../../blocks/block-renderer-registry';
|
|
6
|
+
import { useCustomBlockContext } from '../block-context';
|
|
7
|
+
import {
|
|
8
|
+
getCustomBlockDescriptor,
|
|
9
|
+
getCustomBlockTypes,
|
|
10
|
+
resetCustomCatalog
|
|
11
|
+
} from '../catalog';
|
|
12
|
+
import { resetCustomCollection } from '../collect';
|
|
13
|
+
import { defineCustomBlock } from '../define';
|
|
14
|
+
import { registerCustomBlocks } from '../register';
|
|
15
|
+
import { ThemeSlot } from '../theme-slot';
|
|
16
|
+
import { CUSTOM_LIMITS, isCustomThemeType } from '../types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The registry deliberately has no `unregister` — a build registers once and a
|
|
20
|
+
* storefront never takes a type back. A test file registers dozens of times
|
|
21
|
+
* into that same module state, so it reaches the private Map directly. The
|
|
22
|
+
* reach-in is also the only way to ENUMERATE the registry, which the
|
|
23
|
+
* offered-implies-renderable test below needs in both directions.
|
|
24
|
+
*/
|
|
25
|
+
const rendererMap = (): Map<string, unknown> =>
|
|
26
|
+
(blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
|
|
27
|
+
.renderers;
|
|
28
|
+
|
|
29
|
+
const registeredCustomTypes = (): string[] =>
|
|
30
|
+
Array.from(rendererMap().keys()).filter(isCustomThemeType);
|
|
31
|
+
|
|
32
|
+
const clearCustomRenderers = (): void => {
|
|
33
|
+
const map = rendererMap();
|
|
34
|
+
registeredCustomTypes().forEach((type) => map.delete(type));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const Promo = () => <div>promo</div>;
|
|
38
|
+
const BestSellers = () => <div>best sellers</div>;
|
|
39
|
+
|
|
40
|
+
const props = (over: Partial<BlockRendererProps> = {}): BlockRendererProps => ({
|
|
41
|
+
block: {
|
|
42
|
+
id: 'b1',
|
|
43
|
+
type: 'custom:acme:promo-strip',
|
|
44
|
+
label: 'Promo',
|
|
45
|
+
properties: {},
|
|
46
|
+
styles: {},
|
|
47
|
+
order: 0,
|
|
48
|
+
hidden: false
|
|
49
|
+
},
|
|
50
|
+
placeholderId: 'header',
|
|
51
|
+
sectionId: 's1',
|
|
52
|
+
isDesigner: false,
|
|
53
|
+
...over
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const renderBlock = (type: string, over: Partial<BlockRendererProps> = {}) => {
|
|
57
|
+
const Renderer = blockRendererRegistry.getRenderer(type);
|
|
58
|
+
if (!Renderer) throw new Error(`No renderer registered for ${type}`);
|
|
59
|
+
|
|
60
|
+
return render(<Renderer {...props(over)} />);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
resetCustomCatalog();
|
|
65
|
+
resetCustomCollection();
|
|
66
|
+
clearCustomRenderers();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('registerCustomBlocks — the offered-implies-renderable invariant', () => {
|
|
70
|
+
it('puts every registered block in BOTH the registry and the catalog, and nothing else in either', () => {
|
|
71
|
+
registerCustomBlocks('acme', {
|
|
72
|
+
'promo-strip': defineCustomBlock(Promo, { label: 'Promo strip' }),
|
|
73
|
+
// A bare component: someone will forget the wrapper, and a block with no
|
|
74
|
+
// declared options is a complete block.
|
|
75
|
+
'best-sellers': BestSellers
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const expected = new Set([
|
|
79
|
+
'custom:acme:promo-strip',
|
|
80
|
+
'custom:acme:best-sellers'
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(new Set(getCustomBlockTypes())).toEqual(expected);
|
|
84
|
+
expect(new Set(registeredCustomTypes())).toEqual(expected);
|
|
85
|
+
// Both directions between the two stores: a type the editor is offered but
|
|
86
|
+
// the build cannot render (or the reverse) is the failure this locks.
|
|
87
|
+
expect(new Set(registeredCustomTypes())).toEqual(
|
|
88
|
+
new Set(getCustomBlockTypes())
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('renders the developer component through the registered host', () => {
|
|
93
|
+
registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
94
|
+
|
|
95
|
+
const { container } = renderBlock('custom:acme:promo-strip');
|
|
96
|
+
|
|
97
|
+
expect(container.textContent).toContain('promo');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('registerCustomBlocks — refusing to shadow', () => {
|
|
102
|
+
it('throws rather than overwriting a type something else already claimed', () => {
|
|
103
|
+
// Simulates the other barrel/build that got there first. `register` is a
|
|
104
|
+
// bare Map.set, so without the refusal this call would silently win.
|
|
105
|
+
blockRendererRegistry.register('custom:acme:promo-strip', () => null);
|
|
106
|
+
|
|
107
|
+
expect(() =>
|
|
108
|
+
registerCustomBlocks('acme', { 'promo-strip': Promo })
|
|
109
|
+
).toThrow(/already registered/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('throws when a second call re-registers the same name', () => {
|
|
113
|
+
// A record written as an object literal cannot express the same name
|
|
114
|
+
// twice — the engine collapses duplicate keys before registration sees
|
|
115
|
+
// them — so this is the observable form of a duplicate claim. The in-call
|
|
116
|
+
// guard exists for the same collision arriving from a record that was not
|
|
117
|
+
// written as a literal, and keeps phase 1 complete so phase 2 cannot throw
|
|
118
|
+
// half way through a barrel.
|
|
119
|
+
registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
120
|
+
|
|
121
|
+
expect(() =>
|
|
122
|
+
registerCustomBlocks('acme', { 'promo-strip': BestSellers })
|
|
123
|
+
).toThrow(/already registered/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('registerCustomBlocks — naming', () => {
|
|
128
|
+
it('throws on a vendor that is not a valid segment', () => {
|
|
129
|
+
expect(() => registerCustomBlocks('Acme Inc', { promo: Promo })).toThrow(
|
|
130
|
+
/vendor "Acme Inc"/
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws on a block name that is not a valid segment', () => {
|
|
135
|
+
expect(() => registerCustomBlocks('acme', { Promo_Strip: Promo })).toThrow(
|
|
136
|
+
/"Promo_Strip"/
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('registerCustomBlocks — setting validation', () => {
|
|
142
|
+
it('throws naming the reserved key', () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
registerCustomBlocks('acme', {
|
|
145
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
146
|
+
settings: [{ key: 'items', type: 'text' }]
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
).toThrow(/"items" is reserved/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('throws naming an unsupported control type and why it cannot deliver a value', () => {
|
|
153
|
+
expect(() =>
|
|
154
|
+
registerCustomBlocks('acme', {
|
|
155
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
156
|
+
settings: [{ key: 'tags', type: 'multiselect' }]
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
).toThrow(/"multiselect" cannot deliver a value/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('throws on a key the part grammar cannot carry', () => {
|
|
163
|
+
expect(() =>
|
|
164
|
+
registerCustomBlocks('acme', {
|
|
165
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
166
|
+
settings: [{ key: 'promo/tone', type: 'text' }]
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
).toThrow(/not a valid setting key/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('materializes a scalar default into a per-breakpoint value', () => {
|
|
173
|
+
registerCustomBlocks('acme', {
|
|
174
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
175
|
+
settings: [
|
|
176
|
+
{
|
|
177
|
+
key: 'tone',
|
|
178
|
+
type: 'select',
|
|
179
|
+
label: 'Tone',
|
|
180
|
+
options: ['light', 'dark'],
|
|
181
|
+
defaultValue: 'light'
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
})
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
188
|
+
|
|
189
|
+
expect(descriptor?.settings[0].defaultValue).toEqual({ desktop: 'light' });
|
|
190
|
+
// The shorthand option list is normalized into what the panel renders.
|
|
191
|
+
expect(descriptor?.settings[0].options).toEqual([
|
|
192
|
+
{ label: 'light', value: 'light' },
|
|
193
|
+
{ label: 'dark', value: 'dark' }
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('registerCustomBlocks — declared slots', () => {
|
|
199
|
+
it('keeps every declared name, the unnamed one included', () => {
|
|
200
|
+
registerCustomBlocks('acme', {
|
|
201
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
202
|
+
slots: [
|
|
203
|
+
{ label: 'Body' },
|
|
204
|
+
{ name: 'promo', label: 'Promo content' },
|
|
205
|
+
{ name: 'aside' }
|
|
206
|
+
]
|
|
207
|
+
})
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
211
|
+
|
|
212
|
+
// The name is what `<ThemeSlot name>` matches against the slot node, so it
|
|
213
|
+
// has to survive registration verbatim — the empty string included.
|
|
214
|
+
expect(descriptor?.slots).toEqual([
|
|
215
|
+
{ name: '', label: 'Body' },
|
|
216
|
+
{ name: 'promo', label: 'Promo content' },
|
|
217
|
+
{ name: 'aside' }
|
|
218
|
+
]);
|
|
219
|
+
expect(descriptor?.hasChildren).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('throws naming a slot declared twice', () => {
|
|
223
|
+
expect(() =>
|
|
224
|
+
registerCustomBlocks('acme', {
|
|
225
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
226
|
+
slots: [{ name: 'promo' }, { name: 'promo' }]
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
).toThrow(/declares the slot "promo" twice/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('throws on two unnamed slots, which resolve to the same node', () => {
|
|
233
|
+
expect(() =>
|
|
234
|
+
registerCustomBlocks('acme', {
|
|
235
|
+
'promo-strip': defineCustomBlock(Promo, { slots: [{}, {}] })
|
|
236
|
+
})
|
|
237
|
+
).toThrow(/two unnamed slots/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('throws on a name that could not be matched against a node', () => {
|
|
241
|
+
expect(() =>
|
|
242
|
+
registerCustomBlocks('acme', {
|
|
243
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
244
|
+
slots: [{ name: 'Promo Content' }]
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
).toThrow(/"Promo Content" is not valid/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `accepts` and `max` are the only descriptor values pz-theme carries and
|
|
252
|
+
* never reads, so nothing HERE would notice a bad one — but the editor
|
|
253
|
+
* refuses the whole catalog over it, and a refused catalog empties a brand's
|
|
254
|
+
* entire palette with the only diagnosis sitting in a repo the developer is
|
|
255
|
+
* not working in. These four are the shapes its validator rejects, refused
|
|
256
|
+
* where the message can name the block that wrote them.
|
|
257
|
+
*/
|
|
258
|
+
it('throws on an accepts list no block could ever satisfy', () => {
|
|
259
|
+
expect(() =>
|
|
260
|
+
registerCustomBlocks('acme', {
|
|
261
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
262
|
+
slots: [{ name: 'promo', accepts: [] }]
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
).toThrow(/empty accepts list/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('throws on an accepts entry that is not a block type', () => {
|
|
269
|
+
expect(() =>
|
|
270
|
+
registerCustomBlocks('acme', {
|
|
271
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
272
|
+
slots: [{ name: 'promo', accepts: ['image', ''] }]
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
).toThrow(/malformed accepts list/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('throws on a max that is not a count of blocks', () => {
|
|
279
|
+
expect(() =>
|
|
280
|
+
registerCustomBlocks('acme', {
|
|
281
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
282
|
+
slots: [{ name: 'promo', max: 0 }]
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
).toThrow(/max 0, which is not a positive whole number/);
|
|
286
|
+
|
|
287
|
+
expect(() =>
|
|
288
|
+
registerCustomBlocks('acme', {
|
|
289
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
290
|
+
slots: [{ name: 'promo', max: 1.5 }]
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
).toThrow(/max 1.5, which is not a positive whole number/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('carries a well-formed accepts and max through untouched', () => {
|
|
297
|
+
registerCustomBlocks('acme', {
|
|
298
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
299
|
+
slots: [{ name: 'promo', accepts: ['image', 'text'], max: 2 }]
|
|
300
|
+
})
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(getCustomBlockDescriptor('custom:acme:promo-strip')?.slots).toEqual([
|
|
304
|
+
{ name: 'promo', accepts: ['image', 'text'], max: 2 }
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('registerCustomBlocks — declared parts', () => {
|
|
310
|
+
it('joins the declared parts to the field-derived ones in one list', () => {
|
|
311
|
+
registerCustomBlocks('acme', {
|
|
312
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
313
|
+
settings: [
|
|
314
|
+
{ key: 'headline', type: 'text', label: 'Headline', editable: true }
|
|
315
|
+
],
|
|
316
|
+
parts: ['row', 'tile']
|
|
317
|
+
})
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
321
|
+
|
|
322
|
+
expect(descriptor?.parts).toEqual(['field-headline', 'row', 'tile']);
|
|
323
|
+
// Nothing declared a state, so nothing rides along.
|
|
324
|
+
expect(descriptor?.partStates).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('carries the states of the record form and drops them for the rest', () => {
|
|
328
|
+
registerCustomBlocks('acme', {
|
|
329
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
330
|
+
parts: {
|
|
331
|
+
row: { states: ['hover', 'open'] },
|
|
332
|
+
tile: true,
|
|
333
|
+
badge: {}
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
339
|
+
|
|
340
|
+
expect(descriptor?.parts).toEqual(['row', 'tile', 'badge']);
|
|
341
|
+
expect(descriptor?.partStates).toEqual({ row: ['hover', 'open'] });
|
|
342
|
+
// The wire shape stays JSON, states and all.
|
|
343
|
+
expect(descriptor).toEqual(JSON.parse(JSON.stringify(descriptor)));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('throws on a name that could not survive into a selector', () => {
|
|
347
|
+
expect(() =>
|
|
348
|
+
registerCustomBlocks('acme', {
|
|
349
|
+
'promo-strip': defineCustomBlock(Promo, { parts: ['menu row'] })
|
|
350
|
+
})
|
|
351
|
+
).toThrow(/could not survive into a CSS selector/);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('throws on a name that collides with an auto-derived field part', () => {
|
|
355
|
+
expect(() =>
|
|
356
|
+
registerCustomBlocks('acme', {
|
|
357
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
358
|
+
settings: [
|
|
359
|
+
{ key: 'headline', type: 'text', label: 'Headline', editable: true }
|
|
360
|
+
],
|
|
361
|
+
parts: ['field-headline']
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
).toThrow(/reserved for the parts ThemeEditable derives/);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('throws naming a part declared twice', () => {
|
|
368
|
+
expect(() =>
|
|
369
|
+
registerCustomBlocks('acme', {
|
|
370
|
+
'promo-strip': defineCustomBlock(Promo, { parts: ['row', 'row'] })
|
|
371
|
+
})
|
|
372
|
+
).toThrow(/declares the part "row" twice/);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('throws naming a state the renderer has no selector for', () => {
|
|
376
|
+
expect(() =>
|
|
377
|
+
registerCustomBlocks('acme', {
|
|
378
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
379
|
+
parts: { row: { states: ['hover', 'sparkling'] } }
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
).toThrow(/"sparkling" has no selector behind it/);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('names the states that ARE available when it refuses one', () => {
|
|
386
|
+
// A refusal a developer cannot act on is a refusal they work around by
|
|
387
|
+
// dropping the state entirely.
|
|
388
|
+
expect(() =>
|
|
389
|
+
registerCustomBlocks('acme', {
|
|
390
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
391
|
+
parts: { row: { states: ['sparkling'] } }
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
).toThrow(/Available: .*\bhover\b.*\bopen\b/);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('registerCustomBlocks — descriptor serialization', () => {
|
|
399
|
+
it('survives its own JSON round trip unchanged', () => {
|
|
400
|
+
registerCustomBlocks('acme', {
|
|
401
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
402
|
+
label: 'Promo strip',
|
|
403
|
+
icon: 'star',
|
|
404
|
+
group: 'Marketing',
|
|
405
|
+
settings: [
|
|
406
|
+
{
|
|
407
|
+
key: 'headline',
|
|
408
|
+
type: 'text',
|
|
409
|
+
label: 'Headline',
|
|
410
|
+
defaultValue: 'New season',
|
|
411
|
+
editable: true
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
key: 'tone',
|
|
415
|
+
type: 'select',
|
|
416
|
+
label: 'Tone',
|
|
417
|
+
options: [{ label: 'Light', value: 'light' }],
|
|
418
|
+
showWhen: { key: 'headline', value: 'New season' }
|
|
419
|
+
}
|
|
420
|
+
],
|
|
421
|
+
slots: [{ label: 'Content' }]
|
|
422
|
+
})
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
426
|
+
|
|
427
|
+
expect(descriptor).toEqual(JSON.parse(JSON.stringify(descriptor)));
|
|
428
|
+
expect(descriptor?.hasChildren).toBe(true);
|
|
429
|
+
// An editable setting is addressable as a part, so clicking the element on
|
|
430
|
+
// canvas lands on its control.
|
|
431
|
+
expect(descriptor?.parts).toEqual(['field-headline']);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('throws naming the path of a value JSON would drop', () => {
|
|
435
|
+
expect(() =>
|
|
436
|
+
registerCustomBlocks('acme', {
|
|
437
|
+
'promo-strip': defineCustomBlock(Promo, {
|
|
438
|
+
settings: [
|
|
439
|
+
{
|
|
440
|
+
key: 'tone',
|
|
441
|
+
type: 'select',
|
|
442
|
+
// A function cannot cross postMessage; without the mechanical
|
|
443
|
+
// catch the editor would receive this option with no value.
|
|
444
|
+
options: [{ label: 'Light', value: () => 'light' }]
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
).toThrow(/does not survive JSON/);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe('registerCustomBlocks — atomicity', () => {
|
|
454
|
+
it('leaves the first entry unregistered in both stores when a later one is invalid', () => {
|
|
455
|
+
expect(() =>
|
|
456
|
+
registerCustomBlocks('acme', {
|
|
457
|
+
'promo-strip': Promo,
|
|
458
|
+
'best-sellers': defineCustomBlock(BestSellers, {
|
|
459
|
+
settings: [{ key: 'items', type: 'text' }]
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
).toThrow(/"items" is reserved/);
|
|
463
|
+
|
|
464
|
+
// A half-registered barrel is worse than a rejected one: the picker would
|
|
465
|
+
// offer a block the build can render and hide one it cannot.
|
|
466
|
+
expect(getCustomBlockTypes()).toEqual([]);
|
|
467
|
+
expect(registeredCustomTypes()).toEqual([]);
|
|
468
|
+
expect(blockRendererRegistry.hasRenderer('custom:acme:promo-strip')).toBe(
|
|
469
|
+
false
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('CustomBlockHost — a throwing developer component', () => {
|
|
475
|
+
const Broken = () => {
|
|
476
|
+
throw new Error('brand component blew up');
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
let consoleError: jest.SpyInstance;
|
|
480
|
+
let consoleWarn: jest.SpyInstance;
|
|
481
|
+
|
|
482
|
+
beforeEach(() => {
|
|
483
|
+
// React logs every caught render error itself, and devWarn logs ours.
|
|
484
|
+
consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
485
|
+
consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
afterEach(() => {
|
|
489
|
+
consoleError.mockRestore();
|
|
490
|
+
consoleWarn.mockRestore();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('renders nothing on the live site and does not rethrow', () => {
|
|
494
|
+
registerCustomBlocks('acme', { 'promo-strip': Broken });
|
|
495
|
+
|
|
496
|
+
const { container } = renderBlock('custom:acme:promo-strip', {
|
|
497
|
+
isDesigner: false
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(container).toBeEmptyDOMElement();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('renders a labelled box in the designer', () => {
|
|
504
|
+
registerCustomBlocks('acme', {
|
|
505
|
+
'promo-strip': defineCustomBlock(Broken, { label: 'Promo strip' })
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const { container } = renderBlock('custom:acme:promo-strip', {
|
|
509
|
+
isDesigner: true
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(container.textContent).toContain('Promo strip');
|
|
513
|
+
expect(container.textContent).toContain('failed to render');
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
describe('CustomBlockHost — runtime collection of slots', () => {
|
|
518
|
+
const TwoSlots = () => (
|
|
519
|
+
<>
|
|
520
|
+
<ThemeSlot name="promo" label="Promo" />
|
|
521
|
+
<ThemeSlot name="aside" />
|
|
522
|
+
</>
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
it('records each name the component opened, and only once per name', () => {
|
|
526
|
+
registerCustomBlocks('acme', { 'mega-menu': TwoSlots });
|
|
527
|
+
|
|
528
|
+
renderBlock('custom:acme:mega-menu', {
|
|
529
|
+
block: { ...props().block, type: 'custom:acme:mega-menu' }
|
|
530
|
+
});
|
|
531
|
+
// A second placement of the same type must not append the names again.
|
|
532
|
+
renderBlock('custom:acme:mega-menu', {
|
|
533
|
+
block: { ...props().block, type: 'custom:acme:mega-menu' }
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:mega-menu');
|
|
537
|
+
|
|
538
|
+
expect(descriptor?.slots).toEqual([
|
|
539
|
+
{ name: 'promo', label: 'Promo' },
|
|
540
|
+
{ name: 'aside' }
|
|
541
|
+
]);
|
|
542
|
+
// Opening any slot at all is what makes the block droppable.
|
|
543
|
+
expect(descriptor?.hasChildren).toBe(true);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe('CustomBlockHost — runtime collection', () => {
|
|
548
|
+
const Collecting = () => {
|
|
549
|
+
const context = useCustomBlockContext();
|
|
550
|
+
context?.collect?.setting({
|
|
551
|
+
key: 'tone',
|
|
552
|
+
type: 'select',
|
|
553
|
+
label: 'Tone',
|
|
554
|
+
defaultValue: 'light'
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
return <div>collecting</div>;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
it('lets an undeclared setting reach the catalog, once', () => {
|
|
561
|
+
registerCustomBlocks('acme', { 'promo-strip': Collecting });
|
|
562
|
+
|
|
563
|
+
renderBlock('custom:acme:promo-strip');
|
|
564
|
+
// A second placement of the same type must not append the key again.
|
|
565
|
+
renderBlock('custom:acme:promo-strip');
|
|
566
|
+
|
|
567
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
568
|
+
|
|
569
|
+
expect(descriptor?.settings).toHaveLength(1);
|
|
570
|
+
expect(descriptor?.settings[0]).toMatchObject({
|
|
571
|
+
key: 'tone',
|
|
572
|
+
type: 'select',
|
|
573
|
+
defaultValue: { desktop: 'light' }
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('drops one field part rather than pushing the catalog over the parts cap', () => {
|
|
578
|
+
const CollectingEditable = () => {
|
|
579
|
+
const context = useCustomBlockContext();
|
|
580
|
+
context?.collect?.setting({
|
|
581
|
+
key: 'tagline',
|
|
582
|
+
type: 'text',
|
|
583
|
+
label: 'Tagline',
|
|
584
|
+
editable: true
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
return <div>collecting</div>;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// The one place a descriptor can grow past the cap AFTER registration
|
|
591
|
+
// checked it: `buildDescriptor` counts the declared parts and the declared
|
|
592
|
+
// fields together, and an undeclared `ThemeEditable` discovered at render
|
|
593
|
+
// time adds one more. The cap belongs to the EDITOR, which drops the whole
|
|
594
|
+
// catalog above it — so a brand would lose every block in its palette over
|
|
595
|
+
// one field nobody declared.
|
|
596
|
+
const declared = Array.from(
|
|
597
|
+
{ length: CUSTOM_LIMITS.partsPerType },
|
|
598
|
+
(_, index) => `row${index}`
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
registerCustomBlocks('acme', {
|
|
602
|
+
'promo-strip': defineCustomBlock(CollectingEditable, {
|
|
603
|
+
parts: declared
|
|
604
|
+
})
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
renderBlock('custom:acme:promo-strip');
|
|
608
|
+
|
|
609
|
+
const descriptor = getCustomBlockDescriptor('custom:acme:promo-strip');
|
|
610
|
+
|
|
611
|
+
// The setting still arrives, so the field keeps its control in the Content
|
|
612
|
+
// tab; only its styling group is lost.
|
|
613
|
+
expect(descriptor?.settings.map((setting) => setting.key)).toEqual([
|
|
614
|
+
'tagline'
|
|
615
|
+
]);
|
|
616
|
+
expect(descriptor?.parts).toEqual(declared);
|
|
617
|
+
expect(descriptor?.parts).not.toContain('field-tagline');
|
|
618
|
+
});
|
|
619
|
+
});
|