@akinon/pz-theme 2.0.110-beta.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 +5 -4
- 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/src/theme-section.tsx
CHANGED
|
@@ -42,6 +42,13 @@ export interface Section {
|
|
|
42
42
|
locked?: boolean;
|
|
43
43
|
dataSourceId?: string;
|
|
44
44
|
dataSource?: any;
|
|
45
|
+
/**
|
|
46
|
+
* Sent by the editor, never stored: `true` when publishing now would not
|
|
47
|
+
* change what this section's widget is bound to, so the widget's live rows
|
|
48
|
+
* are the right ones to show. Absent while a pick is unsaved, and from an
|
|
49
|
+
* editor that predates the field. See utils/live-rows.ts.
|
|
50
|
+
*/
|
|
51
|
+
dataSourceSaved?: boolean;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
interface ThemeSectionProps {
|
|
@@ -196,12 +203,12 @@ export default function ThemeSection({
|
|
|
196
203
|
): Record<string, unknown>[] => {
|
|
197
204
|
if (!effectiveSection.dataSource?.details) return [];
|
|
198
205
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
// Live rows first in every mode. On the editor canvas the wrapper leaves
|
|
207
|
+
// `data` in place only for a saved pick, so an unsaved pick still shows
|
|
208
|
+
// its snapshot (utils/live-rows.ts).
|
|
209
|
+
const collectionData =
|
|
210
|
+
effectiveSection.dataSource.details.collection?.data ||
|
|
211
|
+
effectiveSection.dataSource.details.collection?.products;
|
|
205
212
|
|
|
206
213
|
if (!collectionData) return [];
|
|
207
214
|
|
|
@@ -199,19 +199,17 @@ const getItemBindingTarget = (block: Block): string => {
|
|
|
199
199
|
};
|
|
200
200
|
|
|
201
201
|
const getCollectionBindingRoot = (
|
|
202
|
-
sectionDataSource: any
|
|
203
|
-
isDesigner: boolean
|
|
202
|
+
sectionDataSource: any
|
|
204
203
|
): Record<string, unknown> | null => {
|
|
205
204
|
if (!sectionDataSource?.details?.collection) {
|
|
206
205
|
return null;
|
|
207
206
|
}
|
|
208
207
|
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
// Live rows first in every mode. On the editor canvas the wrapper leaves
|
|
209
|
+
// `data` in place only for a saved pick, so an unsaved pick still binds to
|
|
210
|
+
// its snapshot (utils/live-rows.ts).
|
|
211
211
|
const collection = sectionDataSource.details.collection;
|
|
212
|
-
const candidates =
|
|
213
|
-
? [collection?.products, collection?.data]
|
|
214
|
-
: [collection?.data, collection?.products];
|
|
212
|
+
const candidates = [collection?.data, collection?.products];
|
|
215
213
|
|
|
216
214
|
for (const candidate of candidates) {
|
|
217
215
|
if (Array.isArray(candidate)) {
|
|
@@ -248,7 +246,6 @@ const getStaticBindingRoot = (
|
|
|
248
246
|
const getSectionBindingRoot = ({
|
|
249
247
|
sectionDataSource,
|
|
250
248
|
pageContext,
|
|
251
|
-
isDesigner = false,
|
|
252
249
|
allowPageContextFallback = false
|
|
253
250
|
}: BindingContext): Record<string, unknown> | null => {
|
|
254
251
|
if (
|
|
@@ -258,10 +255,7 @@ const getSectionBindingRoot = ({
|
|
|
258
255
|
return pageContext;
|
|
259
256
|
}
|
|
260
257
|
|
|
261
|
-
const collectionRoot = getCollectionBindingRoot(
|
|
262
|
-
sectionDataSource,
|
|
263
|
-
isDesigner
|
|
264
|
-
);
|
|
258
|
+
const collectionRoot = getCollectionBindingRoot(sectionDataSource);
|
|
265
259
|
if (collectionRoot) {
|
|
266
260
|
return collectionRoot;
|
|
267
261
|
}
|
|
@@ -765,13 +759,11 @@ export const resolveBlockBindings = ({
|
|
|
765
759
|
block,
|
|
766
760
|
sectionDataSource,
|
|
767
761
|
pageContext,
|
|
768
|
-
isDesigner = false,
|
|
769
762
|
currency
|
|
770
763
|
}: BindingContext & { block: Block }): Block => {
|
|
771
764
|
const bindingRoot = getSectionBindingRoot({
|
|
772
765
|
sectionDataSource,
|
|
773
766
|
pageContext,
|
|
774
|
-
isDesigner,
|
|
775
767
|
// Value bindings resolve against the page context when the section has no
|
|
776
768
|
// data source. The iterator lookup deliberately does not — see
|
|
777
769
|
// getSectionBindingRoot.
|
|
@@ -852,7 +844,6 @@ const parsePositiveNumber = (value: unknown, fallback: number): number => {
|
|
|
852
844
|
const getIteratorItems = ({
|
|
853
845
|
sectionDataSource,
|
|
854
846
|
dataPath,
|
|
855
|
-
isDesigner = false,
|
|
856
847
|
pageContext,
|
|
857
848
|
scopedItems
|
|
858
849
|
}: BindingContext & {
|
|
@@ -869,8 +860,7 @@ const getIteratorItems = ({
|
|
|
869
860
|
|
|
870
861
|
const bindingRoot = getSectionBindingRoot({
|
|
871
862
|
sectionDataSource,
|
|
872
|
-
pageContext
|
|
873
|
-
isDesigner
|
|
863
|
+
pageContext
|
|
874
864
|
});
|
|
875
865
|
|
|
876
866
|
if (!bindingRoot) {
|
|
@@ -1642,7 +1632,6 @@ export const buildIteratorBlock = ({
|
|
|
1642
1632
|
sectionDataSource,
|
|
1643
1633
|
pageContext,
|
|
1644
1634
|
dataPath,
|
|
1645
|
-
isDesigner,
|
|
1646
1635
|
scopedItems
|
|
1647
1636
|
});
|
|
1648
1637
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { buildClientRequestUrl } from '@akinon/next/utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live rows on the editor canvas (ZERO-5084).
|
|
5
|
+
*
|
|
6
|
+
* A collection-backed section carries two row sets:
|
|
7
|
+
*
|
|
8
|
+
* - `details.collection.products` — the SNAPSHOT the editor captured when the
|
|
9
|
+
* merchant picked the collection. Never refreshed afterwards.
|
|
10
|
+
* - `details.collection.data` — the section widget's LIVE answer, which is
|
|
11
|
+
* what the storefront renders (see load-placeholder-sections.ts).
|
|
12
|
+
*
|
|
13
|
+
* The canvas used to rank the snapshot first, so it drifted from the site as
|
|
14
|
+
* soon as the collection's contents changed. It now ranks the live rows first
|
|
15
|
+
* like the storefront does, and this module decides when the canvas may hold
|
|
16
|
+
* live rows at all: only while the editor reports the section's pick as
|
|
17
|
+
* saved (`dataSourceSaved`). Until an unsaved pick is published, the section's
|
|
18
|
+
* widget is still bound to the previous collection, so its live answer would
|
|
19
|
+
* be the wrong rows — the picked snapshot is shown instead.
|
|
20
|
+
*
|
|
21
|
+
* Client-safe: no server-only imports. The server loader imports
|
|
22
|
+
* `carriesProducts` from here so both sides apply one acceptance rule.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Rows kept per `<sectionId>|<bindingKey>` for the designer canvas. */
|
|
26
|
+
export type LiveRowsStore = Map<string, unknown>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A widget answer counts as live rows only when it actually carries a
|
|
30
|
+
* non-empty `products` array: a section with no data source attached answers
|
|
31
|
+
* without the key at all, and an empty list must never replace the editor's
|
|
32
|
+
* snapshot with nothing to render.
|
|
33
|
+
*/
|
|
34
|
+
export const carriesProducts = (widgetData: unknown): boolean =>
|
|
35
|
+
!!widgetData &&
|
|
36
|
+
typeof widgetData === 'object' &&
|
|
37
|
+
Array.isArray((widgetData as { products?: unknown }).products) &&
|
|
38
|
+
(widgetData as { products: unknown[] }).products.length > 0;
|
|
39
|
+
|
|
40
|
+
const readCollection = (
|
|
41
|
+
dataSource: unknown
|
|
42
|
+
): Record<string, unknown> | null => {
|
|
43
|
+
const collection = (
|
|
44
|
+
dataSource as { details?: { collection?: unknown } } | null | undefined
|
|
45
|
+
)?.details?.collection;
|
|
46
|
+
|
|
47
|
+
return collection && typeof collection === 'object'
|
|
48
|
+
? (collection as Record<string, unknown>)
|
|
49
|
+
: null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a section's widget is bound to: the two values publishing writes to it
|
|
54
|
+
* (`product_collection` and `product_limit`). `null` for a source that is not
|
|
55
|
+
* a collection, or one with no collection picked yet.
|
|
56
|
+
*/
|
|
57
|
+
export const collectionBindingKey = (dataSource: unknown): string | null => {
|
|
58
|
+
const collection = readCollection(dataSource);
|
|
59
|
+
if (!collection) return null;
|
|
60
|
+
|
|
61
|
+
const { pk, productLimit } = collection;
|
|
62
|
+
if (pk === undefined || pk === null || pk === '') return null;
|
|
63
|
+
|
|
64
|
+
return `${String(pk)}|${
|
|
65
|
+
productLimit === undefined || productLimit === null
|
|
66
|
+
? ''
|
|
67
|
+
: String(productLimit)
|
|
68
|
+
}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const liveRowsEntryKey = (sectionId: string, bindingKey: string) =>
|
|
72
|
+
`${sectionId}|${bindingKey}`;
|
|
73
|
+
|
|
74
|
+
export interface LiveRowsRequest {
|
|
75
|
+
path: string;
|
|
76
|
+
/**
|
|
77
|
+
* The section's own widget answers for every section, collection-backed or
|
|
78
|
+
* not, so its answer is only taken when it carries products. An answer from
|
|
79
|
+
* the collection's own widget is taken as it is.
|
|
80
|
+
*/
|
|
81
|
+
requireProducts: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The endpoints the canvas asks, in the server loader's order
|
|
86
|
+
* (`resolveSectionDataSource` in load-placeholder-sections.ts):
|
|
87
|
+
*
|
|
88
|
+
* 1. `/widgets/<collection.slug>/`, then `/product_collection_widgets/<slug>/`
|
|
89
|
+
* — only when the source carries the collection's slug;
|
|
90
|
+
* 2. `/widgets/<sectionId>/` — the section's own widget.
|
|
91
|
+
*
|
|
92
|
+
* If the two orders drift, the canvas and the site pick different answers for
|
|
93
|
+
* the same section. A parity test pins them together.
|
|
94
|
+
*/
|
|
95
|
+
export const liveRowsPaths = (
|
|
96
|
+
sectionId: string,
|
|
97
|
+
collection: { slug?: unknown } | null | undefined
|
|
98
|
+
): LiveRowsRequest[] => {
|
|
99
|
+
const paths: LiveRowsRequest[] = [];
|
|
100
|
+
const slug = collection?.slug;
|
|
101
|
+
|
|
102
|
+
if (typeof slug === 'string' && slug) {
|
|
103
|
+
paths.push({ path: `/widgets/${slug}/`, requireProducts: false });
|
|
104
|
+
paths.push({
|
|
105
|
+
path: `/product_collection_widgets/${slug}/`,
|
|
106
|
+
requireProducts: false
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (sectionId) {
|
|
111
|
+
paths.push({ path: `/widgets/${sectionId}/`, requireProducts: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return paths;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fetches a section's live rows from the browser, walking `liveRowsPaths` in
|
|
119
|
+
* order. Cache-busted, so an answer asked for right after a publish is not a
|
|
120
|
+
* copy from before it. Resolves `null` when no endpoint gives a usable answer;
|
|
121
|
+
* never rejects.
|
|
122
|
+
*/
|
|
123
|
+
export const fetchDesignerLiveRows = async (
|
|
124
|
+
sectionId: string,
|
|
125
|
+
collection: { slug?: unknown } | null | undefined
|
|
126
|
+
): Promise<unknown | null> => {
|
|
127
|
+
for (const { path, requireProducts } of liveRowsPaths(
|
|
128
|
+
sectionId,
|
|
129
|
+
collection
|
|
130
|
+
)) {
|
|
131
|
+
let answer: unknown = null;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(
|
|
135
|
+
buildClientRequestUrl(path, { cache: false })
|
|
136
|
+
);
|
|
137
|
+
if (response.ok) {
|
|
138
|
+
answer = await response.json();
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
answer = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!answer || typeof answer !== 'object') continue;
|
|
145
|
+
if (requireProducts && !carriesProducts(answer)) continue;
|
|
146
|
+
|
|
147
|
+
return answer;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The live rows the server already wrote into the document, keyed by the
|
|
155
|
+
* binding the server resolved them for. Seeding from these is what lets a
|
|
156
|
+
* saved section keep the exact rows the storefront rendered, with no request.
|
|
157
|
+
*/
|
|
158
|
+
export const seedLiveRows = (
|
|
159
|
+
sections:
|
|
160
|
+
ReadonlyArray<{ id?: string; dataSource?: unknown }> | null | undefined
|
|
161
|
+
): LiveRowsStore => {
|
|
162
|
+
const store: LiveRowsStore = new Map();
|
|
163
|
+
|
|
164
|
+
(sections || []).forEach((section) => {
|
|
165
|
+
const data = readCollection(section?.dataSource)?.data;
|
|
166
|
+
const bindingKey = collectionBindingKey(section?.dataSource);
|
|
167
|
+
|
|
168
|
+
if (section?.id && bindingKey && data) {
|
|
169
|
+
store.set(liveRowsEntryKey(section.id, bindingKey), data);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return store;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The data source the designer canvas renders a section with.
|
|
178
|
+
*
|
|
179
|
+
* `raw` is the source record as the editor (or theme-config) holds it. Its
|
|
180
|
+
* `details.collection.data` is dropped, and live rows are put back only when
|
|
181
|
+
* the editor reports the section's pick as saved AND rows are held for the
|
|
182
|
+
* binding the record names now. A late answer for a binding the section has
|
|
183
|
+
* since left is stored under that binding's key, so it can never be attached
|
|
184
|
+
* to the new one.
|
|
185
|
+
*
|
|
186
|
+
* A record that carries no snapshot keeps the `data` it came with: there is
|
|
187
|
+
* nothing to protect, and those rows are what the canvas showed before and
|
|
188
|
+
* what the site shows.
|
|
189
|
+
*/
|
|
190
|
+
export const hangDesignerDataSource = (
|
|
191
|
+
section: { id?: string; dataSourceSaved?: unknown } | null | undefined,
|
|
192
|
+
raw: any,
|
|
193
|
+
liveRows: LiveRowsStore
|
|
194
|
+
): any => {
|
|
195
|
+
const collection = readCollection(raw);
|
|
196
|
+
if (!collection) return raw;
|
|
197
|
+
|
|
198
|
+
const bindingKey = collectionBindingKey(raw);
|
|
199
|
+
const rows =
|
|
200
|
+
section?.dataSourceSaved === true && section.id && bindingKey
|
|
201
|
+
? liveRows.get(liveRowsEntryKey(section.id, bindingKey))
|
|
202
|
+
: undefined;
|
|
203
|
+
|
|
204
|
+
if (rows !== undefined) {
|
|
205
|
+
return {
|
|
206
|
+
...raw,
|
|
207
|
+
details: {
|
|
208
|
+
...raw.details,
|
|
209
|
+
collection: { ...collection, data: rows }
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!('data' in collection) || !collection.products) return raw;
|
|
215
|
+
|
|
216
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
217
|
+
const { data: _unsavedOrStale, ...snapshotOnly } = collection;
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
...raw,
|
|
221
|
+
details: {
|
|
222
|
+
...raw.details,
|
|
223
|
+
collection: snapshotOnly
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
};
|