@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 +8 -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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO-5084 — the editor canvas and the storefront must pick the SAME answer
|
|
3
|
+
* for a collection-backed section.
|
|
4
|
+
*
|
|
5
|
+
* The server loader resolves a section's live rows in one order (the
|
|
6
|
+
* collection's own widget when the source carries its slug, then the
|
|
7
|
+
* section's own widget, taken only when it carries products). The canvas
|
|
8
|
+
* fetches them from the browser when the document holds none for the current
|
|
9
|
+
* binding. If the two orders drift, the canvas shows one set of rows and the
|
|
10
|
+
* site another, which is the bug this ticket exists to remove.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
|
|
15
|
+
jest.mock('server-only', () => ({}), { virtual: true });
|
|
16
|
+
|
|
17
|
+
const getWidgetData = jest.fn();
|
|
18
|
+
const getWidgetSchemaData = jest.fn();
|
|
19
|
+
const getCollectionWidgetData = jest.fn();
|
|
20
|
+
|
|
21
|
+
jest.mock('@akinon/next/data/server', () => ({
|
|
22
|
+
getWidgetData: (...args: unknown[]) => getWidgetData(...args),
|
|
23
|
+
getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
|
|
24
|
+
getCollectionWidgetData: (...args: unknown[]) =>
|
|
25
|
+
getCollectionWidgetData(...args),
|
|
26
|
+
getPreviewSnapshot: async () => null
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock(
|
|
30
|
+
'@akinon/next/utils/app-fetch',
|
|
31
|
+
() => ({ __esModule: true, default: jest.fn() }),
|
|
32
|
+
{ virtual: true }
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
jest.mock(
|
|
36
|
+
'@akinon/next/utils/server-variables',
|
|
37
|
+
() => ({ ServerVariables: { locale: 'tr', currency: 'try' } }),
|
|
38
|
+
{ virtual: true }
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
import { loadPlaceholderSections } from '../load-placeholder-sections';
|
|
42
|
+
import { fetchDesignerLiveRows, liveRowsPaths } from '../utils/live-rows';
|
|
43
|
+
|
|
44
|
+
const SLOT = 'homepage';
|
|
45
|
+
const SECTION = 'rmd8i';
|
|
46
|
+
const SOURCE_ID = 'ds-1';
|
|
47
|
+
const COLLECTION_SLUG = 'summer-picks';
|
|
48
|
+
|
|
49
|
+
interface Answers {
|
|
50
|
+
/** `/widgets/<collection slug>/` */
|
|
51
|
+
slugWidget: unknown | null;
|
|
52
|
+
/** `/product_collection_widgets/<collection slug>/` */
|
|
53
|
+
collectionWidget: unknown | null;
|
|
54
|
+
/** `/widgets/<section id>/` */
|
|
55
|
+
sectionWidget: unknown | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const collection = (slug?: string) => ({
|
|
59
|
+
pk: 7,
|
|
60
|
+
productLimit: 6,
|
|
61
|
+
products: [{ pk: 1, name: 'Snapshot' }],
|
|
62
|
+
...(slug ? { slug } : {})
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** The rows the server loader writes into `collection.data`, or null. */
|
|
66
|
+
const serverAnswer = async (slug: string | undefined, answers: Answers) => {
|
|
67
|
+
const dataSources = [
|
|
68
|
+
{
|
|
69
|
+
id: SOURCE_ID,
|
|
70
|
+
name: 'Picks',
|
|
71
|
+
type: 'collection',
|
|
72
|
+
details: { collection: collection(slug) }
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
getWidgetData.mockImplementation(
|
|
77
|
+
async ({ slug: asked }: { slug: string }) => {
|
|
78
|
+
if (asked === SLOT) {
|
|
79
|
+
return {
|
|
80
|
+
slug: SLOT,
|
|
81
|
+
attributes: {
|
|
82
|
+
theme_editor_placeholder: {
|
|
83
|
+
value: JSON.stringify([{ section_slug: SECTION, order: 0 }])
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (asked === 'theme-config') {
|
|
89
|
+
return {
|
|
90
|
+
slug: 'theme-config',
|
|
91
|
+
attributes: {
|
|
92
|
+
theme_dataSources: { value: JSON.stringify(dataSources) }
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (asked === SECTION) return answers.sectionWidget;
|
|
97
|
+
if (asked === COLLECTION_SLUG) return answers.slugWidget;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
getCollectionWidgetData.mockImplementation(
|
|
102
|
+
async ({ slug: asked }: { slug: string }) =>
|
|
103
|
+
asked === COLLECTION_SLUG ? answers.collectionWidget : null
|
|
104
|
+
);
|
|
105
|
+
getWidgetSchemaData.mockResolvedValue({
|
|
106
|
+
schema: {
|
|
107
|
+
[SECTION]: {
|
|
108
|
+
metadata: { name: 'Strip', dataSourceId: SOURCE_ID, blocks: [] }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const loaded = await loadPlaceholderSections(SLOT);
|
|
114
|
+
return loaded?.sections[0]?.dataSource?.details?.collection?.data ?? null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** The rows the canvas fetches from the browser, or null. */
|
|
118
|
+
const canvasAnswer = async (slug: string | undefined, answers: Answers) => {
|
|
119
|
+
const byPath: Record<string, unknown | null> = {
|
|
120
|
+
[`/widgets/${COLLECTION_SLUG}/`]: answers.slugWidget,
|
|
121
|
+
[`/product_collection_widgets/${COLLECTION_SLUG}/`]:
|
|
122
|
+
answers.collectionWidget,
|
|
123
|
+
[`/widgets/${SECTION}/`]: answers.sectionWidget
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
(global as unknown as { fetch: unknown }).fetch = jest.fn(
|
|
127
|
+
async (url: string) => {
|
|
128
|
+
// The shared @akinon/next/utils mock hands the path back unchanged.
|
|
129
|
+
const answer = byPath[url.split('?')[0]] ?? null;
|
|
130
|
+
return answer === null
|
|
131
|
+
? {
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 404,
|
|
134
|
+
json: async () => ({ detail: 'Not found.' })
|
|
135
|
+
}
|
|
136
|
+
: { ok: true, status: 200, json: async () => answer };
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return fetchDesignerLiveRows(SECTION, collection(slug));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const SLUG_ROWS = {
|
|
144
|
+
products: [{ pk: 10, name: 'From the collection widget' }]
|
|
145
|
+
};
|
|
146
|
+
const COLLECTION_ROWS = {
|
|
147
|
+
products: [{ pk: 20, name: 'From product_collection_widgets' }]
|
|
148
|
+
};
|
|
149
|
+
const SECTION_ROWS = {
|
|
150
|
+
slug: SECTION,
|
|
151
|
+
attributes: {},
|
|
152
|
+
products: [{ pk: 30, name: 'From the section widget' }]
|
|
153
|
+
};
|
|
154
|
+
const SECTION_WITHOUT_ROWS = { slug: SECTION, attributes: {}, products: [] };
|
|
155
|
+
|
|
156
|
+
const CASES: Array<[string, string | undefined, Answers]> = [
|
|
157
|
+
[
|
|
158
|
+
'slug: the collection widget wins',
|
|
159
|
+
COLLECTION_SLUG,
|
|
160
|
+
{
|
|
161
|
+
slugWidget: SLUG_ROWS,
|
|
162
|
+
collectionWidget: COLLECTION_ROWS,
|
|
163
|
+
sectionWidget: SECTION_ROWS
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
'slug: product_collection_widgets when the widget has none',
|
|
168
|
+
COLLECTION_SLUG,
|
|
169
|
+
{
|
|
170
|
+
slugWidget: null,
|
|
171
|
+
collectionWidget: COLLECTION_ROWS,
|
|
172
|
+
sectionWidget: SECTION_ROWS
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
'slug: the section widget when neither collection endpoint answers',
|
|
177
|
+
COLLECTION_SLUG,
|
|
178
|
+
{ slugWidget: null, collectionWidget: null, sectionWidget: SECTION_ROWS }
|
|
179
|
+
],
|
|
180
|
+
[
|
|
181
|
+
'slug: nothing when the section widget carries no products either',
|
|
182
|
+
COLLECTION_SLUG,
|
|
183
|
+
{
|
|
184
|
+
slugWidget: null,
|
|
185
|
+
collectionWidget: null,
|
|
186
|
+
sectionWidget: SECTION_WITHOUT_ROWS
|
|
187
|
+
}
|
|
188
|
+
],
|
|
189
|
+
[
|
|
190
|
+
'slug: a collection-widget answer is taken as it is',
|
|
191
|
+
COLLECTION_SLUG,
|
|
192
|
+
{
|
|
193
|
+
slugWidget: { slug: COLLECTION_SLUG, attributes: {} },
|
|
194
|
+
collectionWidget: null,
|
|
195
|
+
sectionWidget: SECTION_ROWS
|
|
196
|
+
}
|
|
197
|
+
],
|
|
198
|
+
[
|
|
199
|
+
'no slug: the section widget',
|
|
200
|
+
undefined,
|
|
201
|
+
{
|
|
202
|
+
slugWidget: SLUG_ROWS,
|
|
203
|
+
collectionWidget: null,
|
|
204
|
+
sectionWidget: SECTION_ROWS
|
|
205
|
+
}
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
'no slug: nothing for an empty product list',
|
|
209
|
+
undefined,
|
|
210
|
+
{
|
|
211
|
+
slugWidget: null,
|
|
212
|
+
collectionWidget: null,
|
|
213
|
+
sectionWidget: SECTION_WITHOUT_ROWS
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
'no slug: nothing when the section widget does not answer',
|
|
218
|
+
undefined,
|
|
219
|
+
{ slugWidget: null, collectionWidget: null, sectionWidget: null }
|
|
220
|
+
]
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
beforeEach(() => {
|
|
224
|
+
getWidgetData.mockReset();
|
|
225
|
+
getWidgetSchemaData.mockReset();
|
|
226
|
+
getCollectionWidgetData.mockReset();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('the canvas asks in the loader order', () => {
|
|
230
|
+
it('lists the endpoints in the loader order', () => {
|
|
231
|
+
expect(liveRowsPaths(SECTION, collection(COLLECTION_SLUG))).toEqual([
|
|
232
|
+
{ path: `/widgets/${COLLECTION_SLUG}/`, requireProducts: false },
|
|
233
|
+
{
|
|
234
|
+
path: `/product_collection_widgets/${COLLECTION_SLUG}/`,
|
|
235
|
+
requireProducts: false
|
|
236
|
+
},
|
|
237
|
+
{ path: `/widgets/${SECTION}/`, requireProducts: true }
|
|
238
|
+
]);
|
|
239
|
+
expect(liveRowsPaths(SECTION, collection())).toEqual([
|
|
240
|
+
{ path: `/widgets/${SECTION}/`, requireProducts: true }
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it.each(CASES)('%s', async (_name, slug, answers) => {
|
|
245
|
+
const server = await serverAnswer(slug, answers);
|
|
246
|
+
const canvas = await canvasAnswer(slug, answers);
|
|
247
|
+
|
|
248
|
+
expect(canvas).toEqual(server);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every renderer that reads a collection's rows used to rank the snapshot
|
|
254
|
+
* first whenever it was framed by the editor. The canvas now gets live rows
|
|
255
|
+
* only when the wrapper put them there for a saved pick, so the readers rank
|
|
256
|
+
* them first everywhere. This pins all six against a branch coming back.
|
|
257
|
+
*/
|
|
258
|
+
describe('no reader ranks the snapshot above the live rows', () => {
|
|
259
|
+
const read = (relative: string) =>
|
|
260
|
+
fs
|
|
261
|
+
.readFileSync(path.join(__dirname, '..', relative), 'utf8')
|
|
262
|
+
.replace(/\s+/g, ' ');
|
|
263
|
+
|
|
264
|
+
const READERS: Array<[string, string]> = [
|
|
265
|
+
[
|
|
266
|
+
'theme-section.tsx',
|
|
267
|
+
'effectiveSection.dataSource.details.collection?.data || effectiveSection.dataSource.details.collection?.products'
|
|
268
|
+
],
|
|
269
|
+
[
|
|
270
|
+
'utils/iterator-utils.ts',
|
|
271
|
+
'const candidates = [collection?.data, collection?.products];'
|
|
272
|
+
],
|
|
273
|
+
[
|
|
274
|
+
'sections/posts-slider-section.tsx',
|
|
275
|
+
'details.collection?.data?.products, details.collection?.data, details.collection?.products,'
|
|
276
|
+
],
|
|
277
|
+
[
|
|
278
|
+
'sections/featured-product-spotlight-section.tsx',
|
|
279
|
+
'collectionDetails?.data || collectionDetails?.products'
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
'sections/installment-options-section.tsx',
|
|
283
|
+
'collectionDetails?.data || collectionDetails?.products'
|
|
284
|
+
],
|
|
285
|
+
[
|
|
286
|
+
'sections/pre-order-launch-banner-section.tsx',
|
|
287
|
+
'collectionDetails?.data || collectionDetails?.products'
|
|
288
|
+
]
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
it.each(READERS)('%s', (file, liveFirst) => {
|
|
292
|
+
const source = read(file);
|
|
293
|
+
|
|
294
|
+
expect(source).toContain(liveFirst);
|
|
295
|
+
expect(source).not.toMatch(/collection\w*\?\.products \|\| \w+/);
|
|
296
|
+
expect(source).not.toMatch(/window\.parent !== window/);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -147,8 +147,8 @@ describe('collection-backed sections on the server', () => {
|
|
|
147
147
|
sectionWidget: { products: LIVE_ROWS }
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
// The
|
|
151
|
-
//
|
|
150
|
+
// The editor canvas falls back to `products` while a pick is unsaved
|
|
151
|
+
// (ZERO-5084), so the snapshot has to survive next to the live rows.
|
|
152
152
|
expect(section?.dataSource.details.collection.products).toEqual(
|
|
153
153
|
SNAPSHOT_ROWS
|
|
154
154
|
);
|
|
@@ -279,6 +279,17 @@ import fails the brand's build with "cannot be imported from a Client Component
|
|
|
279
279
|
module". `@akinon/pz-theme/src/custom` does not re-export it either; the deep
|
|
280
280
|
module is the one route.
|
|
281
281
|
|
|
282
|
+
**A section that draws its collection's rows itself** reads them off its node,
|
|
283
|
+
the way the built-in renderers do: `dataSource.details.collection.data` (the
|
|
284
|
+
section widget's live answer, what the storefront shows) first, then
|
|
285
|
+
`dataSource.details.collection.products` (the snapshot taken when the merchant
|
|
286
|
+
picked the collection). On the editor canvas `data` is only present while the
|
|
287
|
+
editor reports the pick as saved, and the node then carries
|
|
288
|
+
`dataSourceSaved: true`. Without that flag the pick is not published yet, the
|
|
289
|
+
section's widget still answers for the previous collection, and the snapshot is
|
|
290
|
+
the right thing to draw. A section that fetches its own widget on the canvas
|
|
291
|
+
should do so only when `dataSourceSaved` is `true`.
|
|
292
|
+
|
|
282
293
|
## Parts must live on YOUR markup
|
|
283
294
|
|
|
284
295
|
Part rules are generated with an ownership guard that excludes anything inside a
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
PreviewSnapshot
|
|
14
14
|
} from './chrome/preview-snapshot-source';
|
|
15
15
|
import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
|
|
16
|
+
// One acceptance rule for this loader and the designer canvas's client fetch
|
|
17
|
+
// (utils/live-rows.ts), so the two cannot take different answers.
|
|
18
|
+
import { carriesProducts } from './utils/live-rows';
|
|
16
19
|
|
|
17
20
|
const { getWidgetData } = serverData;
|
|
18
21
|
|
|
@@ -74,18 +77,6 @@ const withCollectionData = (dataSource: any, collectionData: unknown): any => ({
|
|
|
74
77
|
}
|
|
75
78
|
});
|
|
76
79
|
|
|
77
|
-
/**
|
|
78
|
-
* A widget answer counts as live rows only when it actually carries a
|
|
79
|
-
* non-empty `products` array: a section with no data source attached answers
|
|
80
|
-
* without the key at all, and an empty list must never replace the editor's
|
|
81
|
-
* snapshot with nothing to render.
|
|
82
|
-
*/
|
|
83
|
-
const carriesProducts = (widgetData: unknown): boolean =>
|
|
84
|
-
!!widgetData &&
|
|
85
|
-
typeof widgetData === 'object' &&
|
|
86
|
-
Array.isArray((widgetData as { products?: unknown }).products) &&
|
|
87
|
-
(widgetData as { products: unknown[] }).products.length > 0;
|
|
88
|
-
|
|
89
80
|
/**
|
|
90
81
|
* Resolves a section's data source reference into the object the renderer
|
|
91
82
|
* expects, fetching collection data for collection-backed sources. Shared by
|
|
@@ -164,8 +155,14 @@ async function resolveSectionDataSource(
|
|
|
164
155
|
* path they take.
|
|
165
156
|
*
|
|
166
157
|
* Only ever fills an ABSENT `data` — a source that already resolved keeps
|
|
167
|
-
* what it resolved to
|
|
168
|
-
*
|
|
158
|
+
* what it resolved to.
|
|
159
|
+
*
|
|
160
|
+
* The editor canvas renders this same document. Its wrapper keeps this
|
|
161
|
+
* `data` only while the editor reports the section's pick as saved
|
|
162
|
+
* (`dataSourceSaved`, ZERO-5084): the widget is then bound to the collection
|
|
163
|
+
* the editor shows, so these rows are exactly the site's. While a pick is
|
|
164
|
+
* unsaved the wrapper drops them and the canvas shows the picked snapshot,
|
|
165
|
+
* `products`. See utils/live-rows.ts.
|
|
169
166
|
*/
|
|
170
167
|
if (
|
|
171
168
|
dataSource.details.collection &&
|
|
@@ -146,17 +146,15 @@ const formatPriceWithCurrency = (
|
|
|
146
146
|
};
|
|
147
147
|
|
|
148
148
|
const getCollectionProducts = (
|
|
149
|
-
section: Section
|
|
150
|
-
isDesigner: boolean
|
|
149
|
+
section: Section
|
|
151
150
|
): Record<string, unknown>[] => {
|
|
152
151
|
const collectionDetails = section.dataSource?.details?.collection;
|
|
153
152
|
const staticData = section.dataSource?.details?.static?.data;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const collectionPayload =
|
|
158
|
-
|
|
159
|
-
: collectionDetails?.data || collectionDetails?.products;
|
|
153
|
+
// Live rows first in every mode. On the editor canvas the wrapper leaves
|
|
154
|
+
// `data` in place only for a saved pick, so an unsaved pick still shows its
|
|
155
|
+
// snapshot (utils/live-rows.ts).
|
|
156
|
+
const collectionPayload =
|
|
157
|
+
collectionDetails?.data || collectionDetails?.products;
|
|
160
158
|
|
|
161
159
|
if (Array.isArray(collectionPayload)) {
|
|
162
160
|
return collectionPayload as Record<string, unknown>[];
|
|
@@ -260,8 +258,8 @@ export default function FeaturedProductSpotlightSection({
|
|
|
260
258
|
]);
|
|
261
259
|
|
|
262
260
|
const products = useMemo(
|
|
263
|
-
() => getCollectionProducts(section
|
|
264
|
-
[section
|
|
261
|
+
() => getCollectionProducts(section),
|
|
262
|
+
[section]
|
|
265
263
|
);
|
|
266
264
|
const fallbackProductPk = parsePositiveInt(
|
|
267
265
|
getResponsiveValue(
|
|
@@ -259,17 +259,15 @@ const formatPriceWithCurrency = (
|
|
|
259
259
|
};
|
|
260
260
|
|
|
261
261
|
const getCollectionProducts = (
|
|
262
|
-
section: Section
|
|
263
|
-
isDesigner: boolean
|
|
262
|
+
section: Section
|
|
264
263
|
): ProductLike[] => {
|
|
265
264
|
const collectionDetails = section.dataSource?.details?.collection;
|
|
266
265
|
const staticData = section.dataSource?.details?.static?.data;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const collectionPayload =
|
|
271
|
-
|
|
272
|
-
: collectionDetails?.data || collectionDetails?.products;
|
|
266
|
+
// Live rows first in every mode. On the editor canvas the wrapper leaves
|
|
267
|
+
// `data` in place only for a saved pick, so an unsaved pick still shows its
|
|
268
|
+
// snapshot (utils/live-rows.ts).
|
|
269
|
+
const collectionPayload =
|
|
270
|
+
collectionDetails?.data || collectionDetails?.products;
|
|
273
271
|
|
|
274
272
|
if (Array.isArray(collectionPayload)) {
|
|
275
273
|
return collectionPayload as ProductLike[];
|
|
@@ -563,8 +561,8 @@ export default function InstallmentOptionsSection({
|
|
|
563
561
|
);
|
|
564
562
|
|
|
565
563
|
const sectionProducts = useMemo(
|
|
566
|
-
() => getCollectionProducts(section
|
|
567
|
-
[section
|
|
564
|
+
() => getCollectionProducts(section),
|
|
565
|
+
[section]
|
|
568
566
|
);
|
|
569
567
|
const collectionProduct =
|
|
570
568
|
(sectionProducts[0] as ProductLike | undefined) || null;
|
|
@@ -221,8 +221,7 @@ const replaceBlockValues = (
|
|
|
221
221
|
|
|
222
222
|
const getIteratorProducts = (
|
|
223
223
|
section: any,
|
|
224
|
-
iteratorBlock: any
|
|
225
|
-
isDesigner: boolean
|
|
224
|
+
iteratorBlock: any
|
|
226
225
|
): Record<string, unknown>[] => {
|
|
227
226
|
if (!section?.dataSource?.details) return [];
|
|
228
227
|
|
|
@@ -231,23 +230,17 @@ const getIteratorProducts = (
|
|
|
231
230
|
iteratorBlock.properties?.iteratorDataPath;
|
|
232
231
|
|
|
233
232
|
const details = section.dataSource.details;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
details.collection?.data?.products,
|
|
246
|
-
details.collection?.data,
|
|
247
|
-
details.collection?.products,
|
|
248
|
-
details.static?.data,
|
|
249
|
-
details
|
|
250
|
-
];
|
|
233
|
+
|
|
234
|
+
// Live rows first in every mode. On the editor canvas the wrapper leaves
|
|
235
|
+
// `data` in place only for a saved pick, so an unsaved pick still shows its
|
|
236
|
+
// snapshot (utils/live-rows.ts).
|
|
237
|
+
const candidates: unknown[] = [
|
|
238
|
+
details.collection?.data?.products,
|
|
239
|
+
details.collection?.data,
|
|
240
|
+
details.collection?.products,
|
|
241
|
+
details.static?.data,
|
|
242
|
+
details
|
|
243
|
+
];
|
|
251
244
|
|
|
252
245
|
for (const candidate of candidates) {
|
|
253
246
|
if (Array.isArray(candidate)) {
|
|
@@ -345,7 +338,7 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
345
338
|
}
|
|
346
339
|
|
|
347
340
|
const template = block.blocks[0];
|
|
348
|
-
const products = getIteratorProducts(section, block
|
|
341
|
+
const products = getIteratorProducts(section, block);
|
|
349
342
|
const fallbackCount = clampNumber(
|
|
350
343
|
parseNumber(block.properties?.iteratorCount, 1),
|
|
351
344
|
1,
|
|
@@ -237,17 +237,15 @@ const formatLaunchDate = (
|
|
|
237
237
|
};
|
|
238
238
|
|
|
239
239
|
const getCollectionProducts = (
|
|
240
|
-
section: Section
|
|
241
|
-
isDesigner: boolean
|
|
240
|
+
section: Section
|
|
242
241
|
): Record<string, unknown>[] => {
|
|
243
242
|
const collectionDetails = section.dataSource?.details?.collection;
|
|
244
243
|
const staticData = section.dataSource?.details?.static?.data;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const collectionPayload =
|
|
249
|
-
|
|
250
|
-
: collectionDetails?.data || collectionDetails?.products;
|
|
244
|
+
// Live rows first in every mode. On the editor canvas the wrapper leaves
|
|
245
|
+
// `data` in place only for a saved pick, so an unsaved pick still shows its
|
|
246
|
+
// snapshot (utils/live-rows.ts).
|
|
247
|
+
const collectionPayload =
|
|
248
|
+
collectionDetails?.data || collectionDetails?.products;
|
|
251
249
|
|
|
252
250
|
if (Array.isArray(collectionPayload)) {
|
|
253
251
|
return collectionPayload as Record<string, unknown>[];
|
|
@@ -363,8 +361,8 @@ export default function PreOrderLaunchBannerSection({
|
|
|
363
361
|
]);
|
|
364
362
|
|
|
365
363
|
const products = useMemo(
|
|
366
|
-
() => getCollectionProducts(section
|
|
367
|
-
[section
|
|
364
|
+
() => getCollectionProducts(section),
|
|
365
|
+
[section]
|
|
368
366
|
);
|
|
369
367
|
const fallbackProductPk = parsePositiveInt(
|
|
370
368
|
getResponsiveValue(
|
|
@@ -27,6 +27,14 @@ import {
|
|
|
27
27
|
HOVER_REVEAL_CSS
|
|
28
28
|
} from './utils/property-switches';
|
|
29
29
|
import { isPublishWindowVisible } from './utils/publish-window';
|
|
30
|
+
import {
|
|
31
|
+
collectionBindingKey,
|
|
32
|
+
fetchDesignerLiveRows,
|
|
33
|
+
hangDesignerDataSource,
|
|
34
|
+
liveRowsEntryKey,
|
|
35
|
+
LiveRowsStore,
|
|
36
|
+
seedLiveRows
|
|
37
|
+
} from './utils/live-rows';
|
|
30
38
|
import { buildClientRequestUrl } from '@akinon/next/utils';
|
|
31
39
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
32
40
|
import { parseEditorMessage, postToEditor } from './editor-bridge';
|
|
@@ -417,6 +425,24 @@ export default function ThemePlaceholderWrapper({
|
|
|
417
425
|
true;
|
|
418
426
|
}, []);
|
|
419
427
|
const [dataSources, setDataSources] = useState<any[]>(initialDataSources);
|
|
428
|
+
// Live rows for collection-backed sections on the designer canvas, keyed by
|
|
429
|
+
// section and binding (utils/live-rows.ts). Seeded from the rows the server
|
|
430
|
+
// wrote into this document, so a saved section shows exactly what the
|
|
431
|
+
// storefront rendered without a request.
|
|
432
|
+
const [liveRowsSeed] = useState(() => seedLiveRows(initialSections));
|
|
433
|
+
const liveRowsRef = useRef<LiveRowsStore>(liveRowsSeed);
|
|
434
|
+
const liveRowsAttemptedRef = useRef<Set<string>>(new Set());
|
|
435
|
+
const liveRowsMountedRef = useRef(true);
|
|
436
|
+
const [liveRowsRevision, setLiveRowsRevision] = useState(0);
|
|
437
|
+
// Whether the editor has sent its theme for this placeholder yet. Only its
|
|
438
|
+
// sections say which picks are saved (`dataSourceSaved`).
|
|
439
|
+
const editorThemeHeardRef = useRef(false);
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
liveRowsMountedRef.current = true;
|
|
442
|
+
return () => {
|
|
443
|
+
liveRowsMountedRef.current = false;
|
|
444
|
+
};
|
|
445
|
+
}, []);
|
|
420
446
|
const [themeSettings, setThemeSettings] = useState<Record<
|
|
421
447
|
string,
|
|
422
448
|
unknown
|
|
@@ -720,8 +746,12 @@ export default function ThemePlaceholderWrapper({
|
|
|
720
746
|
}
|
|
721
747
|
}, []);
|
|
722
748
|
|
|
749
|
+
// Re-hangs the editor's data-source records over the sections, and again
|
|
750
|
+
// whenever live rows arrive (`liveRowsRevision`). Waits for the editor's
|
|
751
|
+
// theme: until it arrives nothing says which picks are unsaved, and the
|
|
752
|
+
// server-rendered rows are the ones the storefront shows.
|
|
723
753
|
useEffect(() => {
|
|
724
|
-
if (!isDesigner) return;
|
|
754
|
+
if (!isDesigner || !editorThemeHeardRef.current) return;
|
|
725
755
|
if (dataSources && dataSources.length > 0) {
|
|
726
756
|
setSections((prevSections) => {
|
|
727
757
|
return prevSections.map((section) => {
|
|
@@ -732,14 +762,56 @@ export default function ThemePlaceholderWrapper({
|
|
|
732
762
|
|
|
733
763
|
return {
|
|
734
764
|
...section,
|
|
735
|
-
dataSource:
|
|
765
|
+
dataSource: hangDesignerDataSource(
|
|
766
|
+
section,
|
|
767
|
+
dataSource || section.dataSource,
|
|
768
|
+
liveRowsRef.current
|
|
769
|
+
)
|
|
736
770
|
};
|
|
737
771
|
}
|
|
738
772
|
return section;
|
|
739
773
|
});
|
|
740
774
|
});
|
|
741
775
|
}
|
|
742
|
-
}, [dataSources, isDesigner]);
|
|
776
|
+
}, [dataSources, isDesigner, liveRowsRevision]);
|
|
777
|
+
|
|
778
|
+
// A section whose pick is saved but whose current binding has no rows yet —
|
|
779
|
+
// it was re-pointed and published in this session, the document is a draft
|
|
780
|
+
// preview (whose blob path writes no live rows), or the server could not
|
|
781
|
+
// resolve them — asks the storefront for them once. Until they arrive the
|
|
782
|
+
// picked snapshot stays on the canvas: it is the same collection.
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
if (!isDesigner) return;
|
|
785
|
+
|
|
786
|
+
sections.forEach((section) => {
|
|
787
|
+
if (section.dataSourceSaved !== true || !section.id) return;
|
|
788
|
+
|
|
789
|
+
const bindingKey = collectionBindingKey(section.dataSource);
|
|
790
|
+
if (!bindingKey) return;
|
|
791
|
+
|
|
792
|
+
const entryKey = liveRowsEntryKey(section.id, bindingKey);
|
|
793
|
+
if (
|
|
794
|
+
liveRowsRef.current.has(entryKey) ||
|
|
795
|
+
liveRowsAttemptedRef.current.has(entryKey)
|
|
796
|
+
) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
liveRowsAttemptedRef.current.add(entryKey);
|
|
801
|
+
|
|
802
|
+
fetchDesignerLiveRows(
|
|
803
|
+
section.id,
|
|
804
|
+
section.dataSource?.details?.collection
|
|
805
|
+
).then((answer) => {
|
|
806
|
+
if (answer === null || !liveRowsMountedRef.current) return;
|
|
807
|
+
|
|
808
|
+
liveRowsRef.current.set(entryKey, answer);
|
|
809
|
+
setLiveRowsRevision((revision) => revision + 1);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
// `sections` already follows `dataSources`: every change to the records is
|
|
813
|
+
// hung over the sections before this runs.
|
|
814
|
+
}, [sections, isDesigner]);
|
|
743
815
|
|
|
744
816
|
useEffect(() => {
|
|
745
817
|
if (typeof window === 'undefined') {
|
|
@@ -818,6 +890,8 @@ export default function ThemePlaceholderWrapper({
|
|
|
818
890
|
}
|
|
819
891
|
|
|
820
892
|
if (placeholder.sections) {
|
|
893
|
+
editorThemeHeardRef.current = true;
|
|
894
|
+
|
|
821
895
|
// In editor mode: Complete override with theme editor's data
|
|
822
896
|
// This allows unsaved sections to render with editor's product data
|
|
823
897
|
setSections(() => {
|
|
@@ -842,7 +916,13 @@ export default function ThemePlaceholderWrapper({
|
|
|
842
916
|
properties: incomingSection.properties
|
|
843
917
|
? { ...incomingSection.properties }
|
|
844
918
|
: {},
|
|
845
|
-
|
|
919
|
+
// Live rows only for a pick the editor reports as
|
|
920
|
+
// saved; otherwise the picked snapshot.
|
|
921
|
+
dataSource: hangDesignerDataSource(
|
|
922
|
+
incomingSection,
|
|
923
|
+
dataSource || null,
|
|
924
|
+
liveRowsRef.current
|
|
925
|
+
)
|
|
846
926
|
};
|
|
847
927
|
});
|
|
848
928
|
});
|