@akinon/pz-theme 2.0.110-rc.0 → 2.0.110

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 CHANGED
@@ -1,40 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.110-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d45]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.110-rc.0
37
- - @akinon/theme-contract@2.0.110-rc.0
3
+ ## 2.0.110
4
+
5
+ ### Patch Changes
6
+
7
+ - b4e3e64: ZERO-5084: Show a collection section's live rows on the theme editor canvas once its pick is saved
8
+ - Updated dependencies [b4e3e64]
9
+ - @akinon/theme-contract@2.0.110
10
+ - @akinon/next@2.0.110
38
11
 
39
12
  ## 2.0.109
40
13
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.110-rc.0",
4
+ "version": "2.0.110",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.110-rc.0",
11
+ "@akinon/next": "2.0.110",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.110-rc.0",
16
+ "@akinon/theme-contract": "2.0.110",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.110-rc.0",
24
+ "@akinon/next": "2.0.110",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,498 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ import { __resetBrowseModeForTests } from '../browse-mode';
5
+ import { defineCustomBlock, registerCustomBlocks } from '../custom';
6
+ import { useThemeSectionData } from '../theme-section-data-context';
7
+ import { __resetEditorBridgeForTests } from '../editor-bridge';
8
+ import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
9
+
10
+ /**
11
+ * ZERO-5084 — the editor canvas has to show the rows the storefront shows.
12
+ *
13
+ * A collection-backed section carries the SNAPSHOT captured when the merchant
14
+ * picked the collection (`collection.products`) and the section widget's LIVE
15
+ * answer (`collection.data`, written by the server loader). The canvas used to
16
+ * rank the snapshot first, so it drifted from the site as soon as the
17
+ * collection's contents changed (seen on zsa-zsa-zsu: the editor kept showing
18
+ * the six products picked months ago while the site showed today's).
19
+ *
20
+ * The one case where the snapshot is right is an UNSAVED pick: until it is
21
+ * published, the widget is still bound to the previous collection and its live
22
+ * answer is the wrong rows. The editor says which picks are saved with
23
+ * `dataSourceSaved` on each section of LOAD_THEME / UPDATE_THEME.
24
+ */
25
+
26
+ // The shared mock returns the path as-is; the real helper is what decides
27
+ // whether the canvas's request can be answered from a cache.
28
+ jest.mock('@akinon/next/utils', () => {
29
+ const actual = jest.requireActual('@akinon/next/utils');
30
+ return {
31
+ ...actual,
32
+ buildClientRequestUrl: (path: string, options?: { cache?: boolean }) => {
33
+ let url = `/api/client${path}`;
34
+ if (options?.cache === false) {
35
+ url += `${url.includes('?') ? '&' : '?'}t=${Date.now()}`;
36
+ }
37
+ return url;
38
+ }
39
+ };
40
+ });
41
+
42
+ /**
43
+ * Two readers draw the rows below: the section's iterator (iterator-utils)
44
+ * and a block that reads the section's own rows through useThemeSectionData
45
+ * (theme-section), which is what the product-recommendations block does with
46
+ * source 'section'. Both have to agree with the storefront.
47
+ */
48
+ function SectionRows() {
49
+ const data = useThemeSectionData();
50
+ return (
51
+ <p data-testid="section-rows">
52
+ {`rows:${(data?.items || []).map((item) => String(item.name)).join(',')}`}
53
+ </p>
54
+ );
55
+ }
56
+ registerCustomBlocks('livecheck', {
57
+ 'section-rows': defineCustomBlock(SectionRows)
58
+ });
59
+
60
+ const EDITOR_ORIGIN = 'http://localhost:5174';
61
+ const SLOT = 'home-body';
62
+ const SECTION = 'rmd8i';
63
+ const SOURCE_ID = 'datasource-ksy1x';
64
+
65
+ const row = (pk: number, name: string) => ({
66
+ pk,
67
+ name,
68
+ price: '100.00',
69
+ absolute_url: `/p-${pk}/`
70
+ });
71
+
72
+ /** What the merchant picked in the Source panel, months ago. */
73
+ const SNAPSHOT = [row(48, 'Solid Sari Carsaf'), row(71, 'Mino Sari Havlu')];
74
+ /** What the section widget answers today, and what the storefront renders. */
75
+ const LIVE = [row(4157, 'Rajpath Kirlent'), row(4957, 'Como Sezlong')];
76
+ /** The widget's answer after a publish re-pointed it at another collection. */
77
+ const REPOINTED_LIVE = [row(5446, 'Pera Pareo')];
78
+ /** The snapshot of a pick made in this session. */
79
+ const NEW_PICK = [row(900, 'New Pick One')];
80
+
81
+ const collectionSource = (
82
+ collection: Record<string, unknown>,
83
+ id = SOURCE_ID
84
+ ) => ({
85
+ id,
86
+ name: 'Homepage picks',
87
+ type: 'collection',
88
+ details: { collection }
89
+ });
90
+
91
+ const TEMPLATE_BLOCKS = [
92
+ {
93
+ id: 'rows',
94
+ type: 'group',
95
+ label: 'Rows',
96
+ order: 0,
97
+ hidden: false,
98
+ isIterator: true,
99
+ iteratorDataPath: 'products',
100
+ properties: { tag: 'div', iteratorDataPath: 'products', iteratorCount: 2 },
101
+ styles: {},
102
+ blocks: [
103
+ {
104
+ id: 'name',
105
+ type: 'text',
106
+ label: 'Name',
107
+ order: 0,
108
+ hidden: false,
109
+ value: '',
110
+ properties: { dataBinding: 'item.name' },
111
+ styles: {},
112
+ blocks: []
113
+ }
114
+ ]
115
+ },
116
+ {
117
+ id: 'section-rows',
118
+ type: 'custom:livecheck:section-rows',
119
+ label: 'Section rows',
120
+ order: 1,
121
+ hidden: false,
122
+ properties: {},
123
+ styles: {},
124
+ blocks: []
125
+ }
126
+ ];
127
+
128
+ const section = (dataSource: unknown, extra: Record<string, unknown> = {}) =>
129
+ ({
130
+ id: SECTION,
131
+ type: 'generic-section',
132
+ name: 'Product strip',
133
+ label: 'Product strip',
134
+ order: 0,
135
+ hidden: false,
136
+ properties: {},
137
+ styles: {},
138
+ blocks: TEMPLATE_BLOCKS,
139
+ dataSourceId: SOURCE_ID,
140
+ dataSource,
141
+ ...extra
142
+ }) as any;
143
+
144
+ /** The theme-config record, as the server and the editor both hold it. */
145
+ const PUBLISHED = { pk: 1, productLimit: 6, products: SNAPSHOT };
146
+
147
+ /** The section as the server loader resolved it: live rows written in. */
148
+ const serverSection = () =>
149
+ section(collectionSource({ ...PUBLISHED, data: { products: LIVE } }));
150
+
151
+ const frameDocument = () => {
152
+ Object.defineProperty(window, 'top', {
153
+ configurable: true,
154
+ value: { name: 'designer' }
155
+ });
156
+ Object.defineProperty(window, 'parent', {
157
+ configurable: true,
158
+ value: { postMessage: jest.fn() }
159
+ });
160
+ };
161
+
162
+ const unframeDocument = () => {
163
+ Object.defineProperty(window, 'top', { configurable: true, value: window });
164
+ Object.defineProperty(window, 'parent', {
165
+ configurable: true,
166
+ value: window
167
+ });
168
+ };
169
+
170
+ const postFromEditor = (type: string, data: unknown) =>
171
+ act(() => {
172
+ fireEvent(
173
+ window,
174
+ new MessageEvent('message', {
175
+ origin: EDITOR_ORIGIN,
176
+ data: { type, data }
177
+ })
178
+ );
179
+ });
180
+
181
+ const turnDesignerOn = () => postFromEditor('SET_THEME_EDITOR_COOKIE', true);
182
+
183
+ /** LOAD_THEME / UPDATE_THEME carrying one section and one data source. */
184
+ const sendTheme = (
185
+ collection: Record<string, unknown>,
186
+ sectionExtra: Record<string, unknown> = {},
187
+ type: 'LOAD_THEME' | 'UPDATE_THEME' = 'LOAD_THEME'
188
+ ) =>
189
+ postFromEditor(type, {
190
+ theme: {
191
+ dataSources: [collectionSource(collection)],
192
+ placeholders: [
193
+ {
194
+ id: SLOT,
195
+ slug: SLOT,
196
+ sections: [section(undefined, sectionExtra)].map(
197
+ ({ dataSource: _unused, ...wire }) => wire
198
+ )
199
+ }
200
+ ]
201
+ }
202
+ });
203
+
204
+ const shows = (names: string[]) => {
205
+ names.forEach((name) => expect(screen.getByText(name)).toBeInTheDocument());
206
+ expect(screen.getByTestId('section-rows').textContent).toBe(
207
+ `rows:${names.join(',')}`
208
+ );
209
+ };
210
+ const hides = (names: string[]) =>
211
+ names.forEach((name) => expect(screen.queryByText(name)).toBeNull());
212
+ const namesOf = (rows: { name: string }[]) => rows.map((r) => r.name);
213
+
214
+ type Deferred = {
215
+ resolve: (value: unknown) => void;
216
+ promise: Promise<unknown>;
217
+ };
218
+ const deferred = (): Deferred => {
219
+ let resolve!: (value: unknown) => void;
220
+ const promise = new Promise((r) => {
221
+ resolve = r;
222
+ });
223
+ return { resolve, promise };
224
+ };
225
+
226
+ const jsonResponse = (body: unknown, status = 200) => ({
227
+ ok: status >= 200 && status < 300,
228
+ status,
229
+ json: async () => body
230
+ });
231
+
232
+ /** Lets pending fetch promise chains settle inside act. */
233
+ const flush = async () => {
234
+ await act(async () => {
235
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
236
+ });
237
+ };
238
+
239
+ const fetchMock = jest.fn();
240
+
241
+ const renderWrapper = (initialSections = [serverSection()]) =>
242
+ render(
243
+ <ThemePlaceholderWrapper
244
+ slug={SLOT}
245
+ initialSections={initialSections}
246
+ initialPlaceholderId={SLOT}
247
+ isDesignMode={false}
248
+ dataSources={[collectionSource(PUBLISHED)]}
249
+ />
250
+ );
251
+
252
+ beforeEach(() => {
253
+ __resetBrowseModeForTests();
254
+ __resetEditorBridgeForTests();
255
+ delete (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner;
256
+ delete (window as { __iframeReadySent?: boolean }).__iframeReadySent;
257
+ fetchMock.mockReset();
258
+ (global as unknown as { fetch: unknown }).fetch = fetchMock;
259
+ });
260
+
261
+ describe('the editor canvas', () => {
262
+ beforeEach(frameDocument);
263
+
264
+ it('keeps the server rows while the editor has not said anything yet', () => {
265
+ renderWrapper();
266
+ turnDesignerOn();
267
+
268
+ // Nothing says a pick is unsaved yet, and these are the storefront's rows.
269
+ shows(namesOf(LIVE));
270
+ hides(namesOf(SNAPSHOT));
271
+ expect(fetchMock).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it('shows the snapshot to an editor that predates dataSourceSaved', () => {
275
+ renderWrapper();
276
+ turnDesignerOn();
277
+ sendTheme(PUBLISHED);
278
+
279
+ // Today's behaviour, unchanged: no flag means "not known to be saved".
280
+ shows(namesOf(SNAPSHOT));
281
+ hides(namesOf(LIVE));
282
+ expect(fetchMock).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it('shows the server rows for a saved, unchanged pick without a request', () => {
286
+ renderWrapper();
287
+ turnDesignerOn();
288
+ sendTheme(PUBLISHED, { dataSourceSaved: true });
289
+
290
+ shows(namesOf(LIVE));
291
+ hides(namesOf(SNAPSHOT));
292
+ expect(fetchMock).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it('keeps the snapshot of an unsaved pick even though the server wrote rows', () => {
296
+ renderWrapper();
297
+ turnDesignerOn();
298
+ // Re-pointed in the Source panel, not published: the widget still answers
299
+ // for the old collection, so its rows would be the wrong ones.
300
+ sendTheme({ pk: 2, productLimit: 6, products: NEW_PICK });
301
+
302
+ shows(namesOf(NEW_PICK));
303
+ hides(namesOf(LIVE));
304
+ hides(namesOf(SNAPSHOT));
305
+ expect(fetchMock).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it('fetches the rows of a pick published in this session, snapshot until then', async () => {
309
+ const answer = deferred();
310
+ fetchMock.mockReturnValueOnce(answer.promise);
311
+
312
+ renderWrapper();
313
+ turnDesignerOn();
314
+ sendTheme({ pk: 2, productLimit: 6, products: NEW_PICK });
315
+ shows(namesOf(NEW_PICK));
316
+
317
+ // Published: the editor now reports the new binding as saved. The server
318
+ // rows belong to the previous binding and must not come back.
319
+ sendTheme(
320
+ { pk: 2, productLimit: 6, products: NEW_PICK },
321
+ { dataSourceSaved: true },
322
+ 'UPDATE_THEME'
323
+ );
324
+ shows(namesOf(NEW_PICK));
325
+ hides(namesOf(LIVE));
326
+
327
+ expect(fetchMock).toHaveBeenCalledTimes(1);
328
+ expect(fetchMock.mock.calls[0][0]).toMatch(
329
+ new RegExp(`^/api/client/widgets/${SECTION}/\\?t=\\d+$`)
330
+ );
331
+
332
+ answer.resolve(jsonResponse({ products: REPOINTED_LIVE }));
333
+ await flush();
334
+
335
+ shows(namesOf(REPOINTED_LIVE));
336
+ hides(namesOf(NEW_PICK));
337
+
338
+ // Once per section and binding: later updates reuse the rows.
339
+ sendTheme(
340
+ { pk: 2, productLimit: 6, products: NEW_PICK },
341
+ { dataSourceSaved: true },
342
+ 'UPDATE_THEME'
343
+ );
344
+ await flush();
345
+ expect(fetchMock).toHaveBeenCalledTimes(1);
346
+ shows(namesOf(REPOINTED_LIVE));
347
+ });
348
+
349
+ it('asks the collection widget first when the source carries its slug', async () => {
350
+ fetchMock
351
+ .mockResolvedValueOnce(jsonResponse({ detail: 'Not found.' }, 404))
352
+ .mockResolvedValueOnce(jsonResponse({ products: REPOINTED_LIVE }));
353
+
354
+ renderWrapper();
355
+ turnDesignerOn();
356
+ sendTheme(
357
+ { pk: 2, slug: 'summer', productLimit: 6, products: NEW_PICK },
358
+ { dataSourceSaved: true }
359
+ );
360
+ await flush();
361
+
362
+ expect(fetchMock.mock.calls.map(([url]) => url.split('?')[0])).toEqual([
363
+ '/api/client/widgets/summer/',
364
+ '/api/client/product_collection_widgets/summer/'
365
+ ]);
366
+ shows(namesOf(REPOINTED_LIVE));
367
+ });
368
+
369
+ it('keeps the snapshot when the widget answers without products', async () => {
370
+ fetchMock.mockResolvedValueOnce(
371
+ jsonResponse({ slug: SECTION, attributes: {}, products: [] })
372
+ );
373
+
374
+ renderWrapper();
375
+ turnDesignerOn();
376
+ sendTheme(
377
+ { pk: 2, productLimit: 6, products: NEW_PICK },
378
+ { dataSourceSaved: true }
379
+ );
380
+ await flush();
381
+
382
+ expect(fetchMock).toHaveBeenCalledTimes(1);
383
+ shows(namesOf(NEW_PICK));
384
+ hides(namesOf(LIVE));
385
+ });
386
+
387
+ it('never attaches a late answer to a binding the section has left', async () => {
388
+ const lateAnswer = deferred();
389
+ fetchMock
390
+ .mockReturnValueOnce(lateAnswer.promise)
391
+ .mockResolvedValueOnce(
392
+ jsonResponse({ products: [row(7, 'Third Pick Live')] })
393
+ );
394
+
395
+ renderWrapper();
396
+ turnDesignerOn();
397
+ sendTheme(
398
+ { pk: 2, productLimit: 6, products: NEW_PICK },
399
+ { dataSourceSaved: true }
400
+ );
401
+ expect(fetchMock).toHaveBeenCalledTimes(1);
402
+
403
+ // Re-pointed again before the answer came back, not published yet.
404
+ const THIRD_PICK = [row(700, 'Third Pick Snapshot')];
405
+ sendTheme(
406
+ { pk: 3, productLimit: 6, products: THIRD_PICK },
407
+ {},
408
+ 'UPDATE_THEME'
409
+ );
410
+
411
+ lateAnswer.resolve(jsonResponse({ products: REPOINTED_LIVE }));
412
+ await flush();
413
+
414
+ shows(namesOf(THIRD_PICK));
415
+ hides(namesOf(REPOINTED_LIVE));
416
+
417
+ // And once that pick is published, its own rows are asked for.
418
+ sendTheme(
419
+ { pk: 3, productLimit: 6, products: THIRD_PICK },
420
+ { dataSourceSaved: true },
421
+ 'UPDATE_THEME'
422
+ );
423
+ await flush();
424
+
425
+ expect(fetchMock).toHaveBeenCalledTimes(2);
426
+ shows(['Third Pick Live']);
427
+ hides(namesOf(REPOINTED_LIVE));
428
+ });
429
+
430
+ it('treats a product-limit change like a new pick', () => {
431
+ renderWrapper();
432
+ turnDesignerOn();
433
+ // Same collection, different limit: publishing changes product_limit, so
434
+ // the server rows (answered for limit 6) are not this binding's rows.
435
+ fetchMock.mockReturnValue(new Promise(() => undefined));
436
+ sendTheme(
437
+ { pk: 1, productLimit: 2, products: SNAPSHOT },
438
+ { dataSourceSaved: true }
439
+ );
440
+
441
+ shows(namesOf(SNAPSHOT));
442
+ hides(namesOf(LIVE));
443
+ expect(fetchMock).toHaveBeenCalledTimes(1);
444
+ });
445
+ });
446
+
447
+ describe('the storefront', () => {
448
+ beforeEach(unframeDocument);
449
+
450
+ it('renders the server rows and asks for nothing', () => {
451
+ const view = renderWrapper();
452
+
453
+ shows(namesOf(LIVE));
454
+ hides(namesOf(SNAPSHOT));
455
+ expect(fetchMock).not.toHaveBeenCalled();
456
+
457
+ // Same markup as a section rendered straight from the server's answer.
458
+ const direct = render(
459
+ <ThemePlaceholderWrapper
460
+ slug="direct"
461
+ initialSections={[serverSection()]}
462
+ initialPlaceholderId="direct"
463
+ isDesignMode={false}
464
+ />
465
+ );
466
+ expect(
467
+ view.container.querySelector(`[data-placeholder="${SLOT}"]`)?.innerHTML
468
+ ).toBe(
469
+ direct.container.querySelector('[data-placeholder="direct"]')?.innerHTML
470
+ );
471
+ });
472
+
473
+ it('ignores a theme frame from an untrusted origin', () => {
474
+ renderWrapper();
475
+ act(() => {
476
+ fireEvent(
477
+ window,
478
+ new MessageEvent('message', {
479
+ origin: 'https://evil.example',
480
+ data: {
481
+ type: 'LOAD_THEME',
482
+ data: {
483
+ theme: {
484
+ dataSources: [collectionSource(PUBLISHED)],
485
+ placeholders: [
486
+ { id: SLOT, slug: SLOT, sections: [section(undefined)] }
487
+ ]
488
+ }
489
+ }
490
+ }
491
+ })
492
+ );
493
+ });
494
+
495
+ shows(namesOf(LIVE));
496
+ expect(fetchMock).not.toHaveBeenCalled();
497
+ });
498
+ });