@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,486 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { CustomBlockContext } from '../block-context';
|
|
5
|
+
import type { CustomBlockContextValue } from '../block-context';
|
|
6
|
+
import { ThemeEditable } from '../theme-editable';
|
|
7
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
8
|
+
|
|
9
|
+
const contextValue = (
|
|
10
|
+
over: Partial<CustomBlockContextValue> = {}
|
|
11
|
+
): CustomBlockContextValue => ({
|
|
12
|
+
block: {
|
|
13
|
+
id: 'b1',
|
|
14
|
+
type: 'custom:acme:promo-strip',
|
|
15
|
+
label: 'Promo strip',
|
|
16
|
+
properties: {},
|
|
17
|
+
styles: {},
|
|
18
|
+
order: 0,
|
|
19
|
+
hidden: false
|
|
20
|
+
},
|
|
21
|
+
placeholderId: 'header',
|
|
22
|
+
sectionId: 's1',
|
|
23
|
+
isDesigner: false,
|
|
24
|
+
currentBreakpoint: 'desktop',
|
|
25
|
+
...over
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Renders inside a custom block whose properties are the merchant's values. */
|
|
29
|
+
const renderInBlock = (
|
|
30
|
+
ui: React.ReactNode,
|
|
31
|
+
over: Partial<CustomBlockContextValue> & {
|
|
32
|
+
properties?: Record<string, unknown>;
|
|
33
|
+
} = {}
|
|
34
|
+
) => {
|
|
35
|
+
const { properties, ...rest } = over;
|
|
36
|
+
const value = contextValue(rest);
|
|
37
|
+
|
|
38
|
+
if (properties) value.block = { ...value.block, properties };
|
|
39
|
+
|
|
40
|
+
return render(
|
|
41
|
+
<CustomBlockContext.Provider value={value}>
|
|
42
|
+
{ui}
|
|
43
|
+
</CustomBlockContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The refusal branch reads `process.env.NODE_ENV` at call time, so flipping it
|
|
49
|
+
* around a render is enough to exercise the production half — no module reset.
|
|
50
|
+
*/
|
|
51
|
+
const inProduction = (assertions: () => void) => {
|
|
52
|
+
const env = process.env as Record<string, string | undefined>;
|
|
53
|
+
const previous = env.NODE_ENV;
|
|
54
|
+
env.NODE_ENV = 'production';
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
assertions();
|
|
58
|
+
} finally {
|
|
59
|
+
env.NODE_ENV = previous;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const Promo = () => <h2>from a component</h2>;
|
|
64
|
+
|
|
65
|
+
describe('ThemeEditable', () => {
|
|
66
|
+
let warnSpy: jest.SpyInstance;
|
|
67
|
+
let errorSpy: jest.SpyInstance;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
resetDevWarnCacheForTests();
|
|
71
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
72
|
+
// React logs every render-phase throw before rethrowing it; the refusal
|
|
73
|
+
// tests expect the throw, so the log is noise.
|
|
74
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
warnSpy.mockRestore();
|
|
79
|
+
errorSpy.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('type inference from the wrapped tag', () => {
|
|
83
|
+
const textTags = [
|
|
84
|
+
'h1',
|
|
85
|
+
'h2',
|
|
86
|
+
'h3',
|
|
87
|
+
'h4',
|
|
88
|
+
'h5',
|
|
89
|
+
'h6',
|
|
90
|
+
'p',
|
|
91
|
+
'span',
|
|
92
|
+
'strong',
|
|
93
|
+
'em',
|
|
94
|
+
'small',
|
|
95
|
+
'label',
|
|
96
|
+
'li',
|
|
97
|
+
'blockquote'
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
it.each(textTags)('<%s> resolves the merchant text', (tag) => {
|
|
101
|
+
const { container } = renderInBlock(
|
|
102
|
+
<ThemeEditable name="headline">
|
|
103
|
+
{React.createElement(tag, null, 'authored')}
|
|
104
|
+
</ThemeEditable>,
|
|
105
|
+
{ properties: { headline: { desktop: 'merchant copy' } } }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const element = container.querySelector(tag) as HTMLElement;
|
|
109
|
+
expect(element).toBeInTheDocument();
|
|
110
|
+
expect(element.textContent).toBe('merchant copy');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('<img> resolves the merchant value onto src', () => {
|
|
114
|
+
const { container } = renderInBlock(
|
|
115
|
+
<ThemeEditable name="art">
|
|
116
|
+
<img src="/default.jpg" alt="promo" />
|
|
117
|
+
</ThemeEditable>,
|
|
118
|
+
{ properties: { art: { desktop: '/merchant.jpg' } } }
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const img = container.querySelector('img') as HTMLImageElement;
|
|
122
|
+
expect(img).toHaveAttribute('src', '/merchant.jpg');
|
|
123
|
+
// Untouched props survive the clone.
|
|
124
|
+
expect(img).toHaveAttribute('alt', 'promo');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.each(['video', 'source'])(
|
|
128
|
+
'<%s> resolves the merchant value onto src',
|
|
129
|
+
(tag) => {
|
|
130
|
+
const { container } = renderInBlock(
|
|
131
|
+
<ThemeEditable name="clip">
|
|
132
|
+
{React.createElement(tag, { src: '/default.mp4' })}
|
|
133
|
+
</ThemeEditable>,
|
|
134
|
+
{ properties: { clip: { desktop: '/merchant.mp4' } } }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(container.querySelector(tag)).toHaveAttribute(
|
|
138
|
+
'src',
|
|
139
|
+
'/merchant.mp4'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it('unwraps the image picker’s { url } shape at either level', () => {
|
|
145
|
+
const { container } = renderInBlock(
|
|
146
|
+
<ThemeEditable name="art">
|
|
147
|
+
<img src="/default.jpg" />
|
|
148
|
+
</ThemeEditable>,
|
|
149
|
+
{ properties: { art: { url: '/picked.jpg' } } }
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(container.querySelector('img')).toHaveAttribute(
|
|
153
|
+
'src',
|
|
154
|
+
'/picked.jpg'
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const nested = renderInBlock(
|
|
158
|
+
<ThemeEditable name="art">
|
|
159
|
+
<img src="/default.jpg" />
|
|
160
|
+
</ThemeEditable>,
|
|
161
|
+
{ properties: { art: { desktop: { url: '/nested.jpg' } } } }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(nested.container.querySelector('img')).toHaveAttribute(
|
|
165
|
+
'src',
|
|
166
|
+
'/nested.jpg'
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('honours an explicit type on a tag that has no inference', () => {
|
|
171
|
+
const { container } = renderInBlock(
|
|
172
|
+
<ThemeEditable name="banner" type="text">
|
|
173
|
+
<div>authored</div>
|
|
174
|
+
</ThemeEditable>,
|
|
175
|
+
{ properties: { banner: { desktop: 'merchant copy' } } }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(container.querySelector('div')?.textContent).toBe('merchant copy');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('links carry two settings', () => {
|
|
183
|
+
it('applies the label and the href from separate keys', () => {
|
|
184
|
+
const { container } = renderInBlock(
|
|
185
|
+
<ThemeEditable name="cta">
|
|
186
|
+
<a href="/sale">Shop now</a>
|
|
187
|
+
</ThemeEditable>,
|
|
188
|
+
{
|
|
189
|
+
properties: {
|
|
190
|
+
cta: { desktop: 'Shop the drop' },
|
|
191
|
+
ctaHref: { desktop: '/new-season' }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const link = container.querySelector('a') as HTMLAnchorElement;
|
|
197
|
+
expect(link.textContent).toBe('Shop the drop');
|
|
198
|
+
expect(link).toHaveAttribute('href', '/new-season');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('reports both settings, the second keyed <name>Href with a url control', () => {
|
|
202
|
+
const collect = { setting: jest.fn(), slot: jest.fn() };
|
|
203
|
+
|
|
204
|
+
renderInBlock(
|
|
205
|
+
<ThemeEditable name="cta">
|
|
206
|
+
<a href="/sale">Shop now</a>
|
|
207
|
+
</ThemeEditable>,
|
|
208
|
+
{ collect }
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const reported = collect.setting.mock.calls.map((call) => call[0]);
|
|
212
|
+
expect(reported.map((setting) => setting.key)).toEqual([
|
|
213
|
+
'cta',
|
|
214
|
+
'ctaHref'
|
|
215
|
+
]);
|
|
216
|
+
expect(reported[0]).toMatchObject({
|
|
217
|
+
type: 'text',
|
|
218
|
+
editable: true,
|
|
219
|
+
defaultValue: { desktop: 'Shop now' }
|
|
220
|
+
});
|
|
221
|
+
expect(reported[1]).toMatchObject({
|
|
222
|
+
type: 'url',
|
|
223
|
+
defaultValue: { desktop: '/sale' }
|
|
224
|
+
});
|
|
225
|
+
// NOT editable, and that is the whole point: `editable` is what
|
|
226
|
+
// `collect.ts` turns into a `field-<key>` part. One ThemeEditable clones
|
|
227
|
+
// one element, the `<a>` already carries `field-cta`, so nothing on the
|
|
228
|
+
// page could ever answer to `field-ctaHref`.
|
|
229
|
+
expect(reported[1]).not.toHaveProperty('editable');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('falls back to each authored half independently', () => {
|
|
233
|
+
const { container } = renderInBlock(
|
|
234
|
+
<ThemeEditable name="cta">
|
|
235
|
+
<a href="/sale">Shop now</a>
|
|
236
|
+
</ThemeEditable>,
|
|
237
|
+
{ properties: { ctaHref: { desktop: '/new-season' } } }
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const link = container.querySelector('a') as HTMLAnchorElement;
|
|
241
|
+
expect(link.textContent).toBe('Shop now');
|
|
242
|
+
expect(link).toHaveAttribute('href', '/new-season');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('the authored JSX is the default', () => {
|
|
247
|
+
it('renders the authored content when the block has no properties', () => {
|
|
248
|
+
const { container } = renderInBlock(
|
|
249
|
+
<ThemeEditable name="headline">
|
|
250
|
+
<h2>New season</h2>
|
|
251
|
+
</ThemeEditable>
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(container.querySelector('h2')?.textContent).toBe('New season');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('a merchant value overrides it', () => {
|
|
258
|
+
const { container } = renderInBlock(
|
|
259
|
+
<ThemeEditable name="headline">
|
|
260
|
+
<h2>New season</h2>
|
|
261
|
+
</ThemeEditable>,
|
|
262
|
+
{ properties: { headline: { desktop: 'Mid season' } } }
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(container.querySelector('h2')?.textContent).toBe('Mid season');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('reports the authored default per breakpoint through the collect sink', () => {
|
|
269
|
+
const collect = { setting: jest.fn(), slot: jest.fn() };
|
|
270
|
+
|
|
271
|
+
renderInBlock(
|
|
272
|
+
<ThemeEditable name="headline">
|
|
273
|
+
<h2>New season</h2>
|
|
274
|
+
</ThemeEditable>,
|
|
275
|
+
{ collect }
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(collect.setting).toHaveBeenCalledWith({
|
|
279
|
+
key: 'headline',
|
|
280
|
+
type: 'text',
|
|
281
|
+
label: 'Headline',
|
|
282
|
+
defaultValue: { desktop: 'New season' },
|
|
283
|
+
editable: true
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('omits an unserializable default but still renders it', () => {
|
|
288
|
+
const collect = { setting: jest.fn(), slot: jest.fn() };
|
|
289
|
+
|
|
290
|
+
const { container } = renderInBlock(
|
|
291
|
+
<ThemeEditable name="headline">
|
|
292
|
+
<h2>
|
|
293
|
+
New <strong>season</strong>
|
|
294
|
+
</h2>
|
|
295
|
+
</ThemeEditable>,
|
|
296
|
+
{ collect }
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(collect.setting).toHaveBeenCalledWith(
|
|
300
|
+
expect.objectContaining({ key: 'headline', defaultValue: undefined })
|
|
301
|
+
);
|
|
302
|
+
expect(container.querySelector('h2')?.textContent).toBe('New season');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('per-breakpoint resolution', () => {
|
|
307
|
+
it('uses the value authored for the active breakpoint', () => {
|
|
308
|
+
const { container } = renderInBlock(
|
|
309
|
+
<ThemeEditable name="headline">
|
|
310
|
+
<h2>New season</h2>
|
|
311
|
+
</ThemeEditable>,
|
|
312
|
+
{
|
|
313
|
+
properties: {
|
|
314
|
+
headline: { desktop: 'Wide copy', mobile: 'Short copy' }
|
|
315
|
+
},
|
|
316
|
+
currentBreakpoint: 'mobile'
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
expect(container.querySelector('h2')?.textContent).toBe('Short copy');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('inherits down the breakpoint chain the way the emitted CSS does', () => {
|
|
324
|
+
const { container } = renderInBlock(
|
|
325
|
+
<ThemeEditable name="headline">
|
|
326
|
+
<h2>New season</h2>
|
|
327
|
+
</ThemeEditable>,
|
|
328
|
+
{
|
|
329
|
+
properties: { headline: { mobile: 'Short copy' } },
|
|
330
|
+
currentBreakpoint: 'tablet'
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(container.querySelector('h2')?.textContent).toBe('Short copy');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('falls back to the authored JSX for a bare empty value', () => {
|
|
338
|
+
const { container } = renderInBlock(
|
|
339
|
+
<ThemeEditable name="headline">
|
|
340
|
+
<h2>New season</h2>
|
|
341
|
+
</ThemeEditable>,
|
|
342
|
+
{ properties: { headline: '' } }
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(container.querySelector('h2')?.textContent).toBe('New season');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('lets a merchant blank the field for one breakpoint', () => {
|
|
349
|
+
const { container } = renderInBlock(
|
|
350
|
+
<ThemeEditable name="headline">
|
|
351
|
+
<h2>New season</h2>
|
|
352
|
+
</ThemeEditable>,
|
|
353
|
+
{ properties: { headline: { desktop: '' } } }
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(container.querySelector('h2')?.textContent).toBe('');
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('editor targeting', () => {
|
|
361
|
+
it('emits data-part="field-<name>"', () => {
|
|
362
|
+
const { container } = renderInBlock(
|
|
363
|
+
<ThemeEditable name="headline">
|
|
364
|
+
<h2>New season</h2>
|
|
365
|
+
</ThemeEditable>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect(container.querySelector('h2')).toHaveAttribute(
|
|
369
|
+
'data-part',
|
|
370
|
+
'field-headline'
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('never clobbers a data-part the developer chose', () => {
|
|
375
|
+
const { container } = renderInBlock(
|
|
376
|
+
<ThemeEditable name="headline">
|
|
377
|
+
<h2 data-part="title">New season</h2>
|
|
378
|
+
</ThemeEditable>
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(container.querySelector('h2')).toHaveAttribute(
|
|
382
|
+
'data-part',
|
|
383
|
+
'title'
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('refusals', () => {
|
|
389
|
+
const cases: Array<[string, React.ReactElement, string]> = [
|
|
390
|
+
[
|
|
391
|
+
'a component child',
|
|
392
|
+
<ThemeEditable name="headline" key="component">
|
|
393
|
+
<Promo />
|
|
394
|
+
</ThemeEditable>,
|
|
395
|
+
'from a component'
|
|
396
|
+
],
|
|
397
|
+
[
|
|
398
|
+
'no element child',
|
|
399
|
+
<ThemeEditable name="headline" key="bare">
|
|
400
|
+
just a string
|
|
401
|
+
</ThemeEditable>,
|
|
402
|
+
'just a string'
|
|
403
|
+
],
|
|
404
|
+
[
|
|
405
|
+
'more than one child',
|
|
406
|
+
<ThemeEditable name="headline" key="many">
|
|
407
|
+
<h2>one</h2>
|
|
408
|
+
<h3>two</h3>
|
|
409
|
+
</ThemeEditable>,
|
|
410
|
+
'onetwo'
|
|
411
|
+
],
|
|
412
|
+
[
|
|
413
|
+
'a container tag with no explicit type',
|
|
414
|
+
<ThemeEditable name="headline" key="container">
|
|
415
|
+
<div>authored</div>
|
|
416
|
+
</ThemeEditable>,
|
|
417
|
+
'authored'
|
|
418
|
+
],
|
|
419
|
+
[
|
|
420
|
+
'a name that is not a usable key',
|
|
421
|
+
<ThemeEditable name="Head Line" key="badname">
|
|
422
|
+
<h2>New season</h2>
|
|
423
|
+
</ThemeEditable>,
|
|
424
|
+
'New season'
|
|
425
|
+
],
|
|
426
|
+
[
|
|
427
|
+
'a name the property panel reserves',
|
|
428
|
+
<ThemeEditable name="items" key="reserved">
|
|
429
|
+
<h2>New season</h2>
|
|
430
|
+
</ThemeEditable>,
|
|
431
|
+
'New season'
|
|
432
|
+
]
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
it.each(cases)('throws in development for %s', (_label, ui) => {
|
|
436
|
+
expect(() => renderInBlock(ui)).toThrow(/\[pz-theme\] <ThemeEditable/);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it.each(cases)(
|
|
440
|
+
'renders the child untouched in production for %s',
|
|
441
|
+
(_label, ui, expectedText) => {
|
|
442
|
+
inProduction(() => {
|
|
443
|
+
const { container } = renderInBlock(ui);
|
|
444
|
+
expect(container.textContent).toBe(expectedText);
|
|
445
|
+
// No field wiring happened, so nothing claims a part.
|
|
446
|
+
expect(container.querySelector('[data-part]')).toBeNull();
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
it('refuses a link whose derived href key would overflow the key pattern', () => {
|
|
452
|
+
const name = 'a'.repeat(30);
|
|
453
|
+
|
|
454
|
+
expect(() =>
|
|
455
|
+
renderInBlock(
|
|
456
|
+
<ThemeEditable name={name}>
|
|
457
|
+
<a href="/sale">Shop now</a>
|
|
458
|
+
</ThemeEditable>
|
|
459
|
+
)
|
|
460
|
+
).toThrow(/companion key/);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('outside a custom block', () => {
|
|
465
|
+
it('renders the child untouched and warns once', () => {
|
|
466
|
+
const { container } = render(
|
|
467
|
+
<ThemeEditable name="headline">
|
|
468
|
+
<h2>New season</h2>
|
|
469
|
+
</ThemeEditable>
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const heading = container.querySelector('h2') as HTMLElement;
|
|
473
|
+
expect(heading.textContent).toBe('New season');
|
|
474
|
+
expect(heading).not.toHaveAttribute('data-part');
|
|
475
|
+
|
|
476
|
+
render(
|
|
477
|
+
<ThemeEditable name="headline">
|
|
478
|
+
<h2>New season</h2>
|
|
479
|
+
</ThemeEditable>
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
483
|
+
expect(warnSpy.mock.calls[0][0]).toContain('is outside a custom block');
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
});
|