@akinon/pz-theme 2.0.110-rc.0 → 2.0.111-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -35
- package/package.json +4 -4
- package/src/__tests__/designer-live-rows.test.tsx +498 -0
- package/src/__tests__/live-rows-parity.test.ts +298 -0
- package/src/__tests__/section-live-rows.test.ts +2 -2
- package/src/custom/README-sections.md +11 -0
- package/src/load-placeholder-sections.ts +11 -14
- package/src/sections/featured-product-spotlight-section.tsx +8 -10
- package/src/sections/installment-options-section.tsx +8 -10
- package/src/sections/posts-slider-section.tsx +13 -20
- package/src/sections/pre-order-launch-banner-section.tsx +8 -10
- package/src/theme-placeholder-wrapper.tsx +84 -4
- package/src/theme-section.tsx +13 -6
- package/src/utils/iterator-utils.ts +7 -18
- package/src/utils/live-rows.ts +226 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,21 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
- Updated dependencies [
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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.111-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.111-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.111-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.110
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- b4e3e64: ZERO-5084: Show a collection section's live rows on the theme editor canvas once its pick is saved
|
|
16
|
+
- Updated dependencies [b4e3e64]
|
|
17
|
+
- @akinon/theme-contract@2.0.110
|
|
18
|
+
- @akinon/next@2.0.110
|
|
38
19
|
|
|
39
20
|
## 2.0.109
|
|
40
21
|
|
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.
|
|
4
|
+
"version": "2.0.111-beta.0",
|
|
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.
|
|
11
|
+
"@akinon/next": "2.0.111-beta.0",
|
|
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.
|
|
16
|
+
"@akinon/theme-contract": "2.0.111-beta.0",
|
|
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.
|
|
24
|
+
"@akinon/next": "2.0.111-beta.0",
|
|
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
|
+
});
|