@akinon/pz-theme 2.0.39-rc.0 → 2.0.39
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 -31
- package/package.json +4 -4
- package/src/__tests__/page-context-section-binding.test.tsx +104 -0
- package/src/chrome/__tests__/live-basket-page-context.test.tsx +109 -0
- package/src/chrome/__tests__/page-context-builders.test.ts +156 -0
- package/src/chrome/live-basket-page-context.tsx +39 -0
- package/src/chrome/live-checkout-page-context.tsx +41 -0
- package/src/chrome/live-order-page-context.tsx +65 -0
- package/src/chrome/page-context.ts +172 -2
- package/src/index.ts +19 -0
- package/src/theme-placeholder-client.tsx +4 -0
- package/src/theme-placeholder-wrapper.tsx +12 -1
- package/src/theme-section.tsx +8 -3
- package/src/utils/__tests__/generator-passthrough.test.ts +92 -0
- package/src/utils/field-semantics.test.ts +72 -0
- package/src/utils/field-semantics.ts +76 -0
- package/src/utils/iterator-utils.test.ts +3 -1
- package/src/utils/iterator-utils.ts +89 -6
- package/src/utils/page-context-binding.test.ts +136 -0
- package/src/utils/page-context-discovery.ts +13 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// a value import of the block module would close that loop at runtime.
|
|
3
3
|
import type { Block } from '../theme-block';
|
|
4
4
|
import { devWarn } from './dev-warn';
|
|
5
|
+
import { isMoneyPath } from './field-semantics';
|
|
5
6
|
import { isVisibleForItem, readRowVisibility } from './row-visibility';
|
|
6
7
|
|
|
7
8
|
interface BindingContext {
|
|
@@ -14,6 +15,19 @@ interface BindingContext {
|
|
|
14
15
|
* running in en-GB got Turkish grouping in every repeated row.
|
|
15
16
|
*/
|
|
16
17
|
locale?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Storefront currency, the last resort when a bound money value carries no
|
|
20
|
+
* currency of its own. Commerce publishes a currency per basket line, so an
|
|
21
|
+
* empty basket has none to read.
|
|
22
|
+
*/
|
|
23
|
+
currency?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Lets the page context stand in as the binding root when the section has no
|
|
26
|
+
* data source of its own. Opt-in rather than automatic: the same root feeds
|
|
27
|
+
* the iterator lookup, and a repeat with no data path would otherwise start
|
|
28
|
+
* reading `items` / `products` off the page context.
|
|
29
|
+
*/
|
|
30
|
+
allowPageContextFallback?: boolean;
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
interface IteratorOptions extends BindingContext {
|
|
@@ -218,7 +232,8 @@ const getStaticBindingRoot = (
|
|
|
218
232
|
const getSectionBindingRoot = ({
|
|
219
233
|
sectionDataSource,
|
|
220
234
|
pageContext,
|
|
221
|
-
isDesigner = false
|
|
235
|
+
isDesigner = false,
|
|
236
|
+
allowPageContextFallback = false
|
|
222
237
|
}: BindingContext): Record<string, unknown> | null => {
|
|
223
238
|
if (
|
|
224
239
|
sectionDataSource?.type === PAGE_CONTEXT_DATA_SOURCE_TYPE &&
|
|
@@ -240,6 +255,14 @@ const getSectionBindingRoot = ({
|
|
|
240
255
|
return staticRoot;
|
|
241
256
|
}
|
|
242
257
|
|
|
258
|
+
// The page's own commerce data is the root a designer means when they bind
|
|
259
|
+
// `basket.totalAmount` on the basket page. Creating a data source first is
|
|
260
|
+
// the step nobody discovers, so a section without one falls back to it —
|
|
261
|
+
// for value bindings only, never for the iterator lookup.
|
|
262
|
+
if (allowPageContextFallback && isRecord(pageContext)) {
|
|
263
|
+
return pageContext;
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
return null;
|
|
244
267
|
};
|
|
245
268
|
|
|
@@ -483,10 +506,52 @@ const isPriceBinding = (dataBinding: string): boolean => {
|
|
|
483
506
|
);
|
|
484
507
|
};
|
|
485
508
|
|
|
509
|
+
/**
|
|
510
|
+
* A bound total used to print "799.90" beside a built-in block printing
|
|
511
|
+
* "1.299,90 TL", because formatting was reached only from the row-scoped
|
|
512
|
+
* product path. Which fields are money is decided in one place — the same one
|
|
513
|
+
* the editor labels its picker from, so the two cannot disagree.
|
|
514
|
+
*/
|
|
515
|
+
const isPageMoneyBinding = (bindingPath: string): boolean =>
|
|
516
|
+
Boolean(bindingPath) &&
|
|
517
|
+
!bindingPath.startsWith('item.') &&
|
|
518
|
+
isMoneyPath(bindingPath);
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The currency that belongs to the bound value: the one its own root publishes
|
|
522
|
+
* (`basket.currency`), then the first row that carries one (an empty basket
|
|
523
|
+
* has no rows), then the storefront's.
|
|
524
|
+
*/
|
|
525
|
+
const readContextCurrency = (
|
|
526
|
+
bindingRoot: Record<string, unknown> | null,
|
|
527
|
+
bindingPath: string,
|
|
528
|
+
fallback?: string
|
|
529
|
+
): string | undefined => {
|
|
530
|
+
const rootKey = bindingPath.split('.')[0];
|
|
531
|
+
const scopeValue = isRecord(bindingRoot) ? bindingRoot[rootKey] : undefined;
|
|
532
|
+
const scope = isRecord(scopeValue) ? scopeValue : null;
|
|
533
|
+
|
|
534
|
+
if (scope) {
|
|
535
|
+
const own = scope.currency;
|
|
536
|
+
if (typeof own === 'string' && own.trim()) return own.trim();
|
|
537
|
+
|
|
538
|
+
const rows = Array.isArray(scope.items) ? scope.items : [];
|
|
539
|
+
for (const row of rows) {
|
|
540
|
+
const rowCurrency = isRecord(row) ? row.currency : undefined;
|
|
541
|
+
if (typeof rowCurrency === 'string' && rowCurrency.trim()) {
|
|
542
|
+
return rowCurrency.trim();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return fallback && fallback.trim() ? fallback.trim() : undefined;
|
|
548
|
+
};
|
|
549
|
+
|
|
486
550
|
const applyResolvedBinding = (
|
|
487
551
|
block: Block,
|
|
488
552
|
resolvedValue: unknown,
|
|
489
|
-
bindingPath: string
|
|
553
|
+
bindingPath: string,
|
|
554
|
+
moneyCurrency?: string
|
|
490
555
|
): Block => {
|
|
491
556
|
const target = getItemBindingTarget(block);
|
|
492
557
|
const nextBlock: Block = {
|
|
@@ -505,6 +570,17 @@ const applyResolvedBinding = (
|
|
|
505
570
|
return nextBlock;
|
|
506
571
|
}
|
|
507
572
|
|
|
573
|
+
if (
|
|
574
|
+
target === 'value' &&
|
|
575
|
+
isPageMoneyBinding(bindingPath) &&
|
|
576
|
+
(typeof resolvedValue === 'string' || typeof resolvedValue === 'number')
|
|
577
|
+
) {
|
|
578
|
+
nextBlock.value = escapeHtml(
|
|
579
|
+
getPriceWithCurrency(resolvedValue, moneyCurrency)
|
|
580
|
+
);
|
|
581
|
+
return nextBlock;
|
|
582
|
+
}
|
|
583
|
+
|
|
508
584
|
const normalizedValue = normalizeScalarValue(
|
|
509
585
|
resolvedValue,
|
|
510
586
|
target,
|
|
@@ -553,12 +629,17 @@ export const resolveBlockBindings = ({
|
|
|
553
629
|
block,
|
|
554
630
|
sectionDataSource,
|
|
555
631
|
pageContext,
|
|
556
|
-
isDesigner = false
|
|
632
|
+
isDesigner = false,
|
|
633
|
+
currency
|
|
557
634
|
}: BindingContext & { block: Block }): Block => {
|
|
558
635
|
const bindingRoot = getSectionBindingRoot({
|
|
559
636
|
sectionDataSource,
|
|
560
637
|
pageContext,
|
|
561
|
-
isDesigner
|
|
638
|
+
isDesigner,
|
|
639
|
+
// Value bindings resolve against the page context when the section has no
|
|
640
|
+
// data source. The iterator lookup deliberately does not — see
|
|
641
|
+
// getSectionBindingRoot.
|
|
642
|
+
allowPageContextFallback: true
|
|
562
643
|
});
|
|
563
644
|
|
|
564
645
|
const walk = (sourceBlock: Block): Block => {
|
|
@@ -592,7 +673,8 @@ export const resolveBlockBindings = ({
|
|
|
592
673
|
resolvedBlock = applyResolvedBinding(
|
|
593
674
|
nextBlock,
|
|
594
675
|
interpolated,
|
|
595
|
-
bindingPath
|
|
676
|
+
bindingPath,
|
|
677
|
+
readContextCurrency(bindingRoot, bindingPath, currency)
|
|
596
678
|
);
|
|
597
679
|
}
|
|
598
680
|
} else {
|
|
@@ -601,7 +683,8 @@ export const resolveBlockBindings = ({
|
|
|
601
683
|
resolvedBlock = applyResolvedBinding(
|
|
602
684
|
nextBlock,
|
|
603
685
|
resolvedValue,
|
|
604
|
-
bindingPath
|
|
686
|
+
bindingPath,
|
|
687
|
+
readContextCurrency(bindingRoot, bindingPath, currency)
|
|
605
688
|
);
|
|
606
689
|
}
|
|
607
690
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { buildIteratorBlock, resolveBlockBindings } from './iterator-utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page a designer is editing is itself a data source: on the basket page
|
|
5
|
+
* `basket.totalAmount` must resolve without the designer creating a data
|
|
6
|
+
* source first, and it must read as money rather than as a raw API string.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const textBlock = (props: Record<string, unknown>, id = 'b') =>
|
|
10
|
+
({
|
|
11
|
+
id,
|
|
12
|
+
type: 'text',
|
|
13
|
+
label: id,
|
|
14
|
+
value: '',
|
|
15
|
+
properties: {},
|
|
16
|
+
styles: {},
|
|
17
|
+
order: 0,
|
|
18
|
+
hidden: false,
|
|
19
|
+
...props
|
|
20
|
+
} as any);
|
|
21
|
+
|
|
22
|
+
const basketContext = (overrides: Record<string, unknown> = {}) => ({
|
|
23
|
+
page: { type: 'basket', title: 'Basket', path: '/basket' },
|
|
24
|
+
basket: {
|
|
25
|
+
isEmpty: false,
|
|
26
|
+
itemCount: 1,
|
|
27
|
+
totalQuantity: 2,
|
|
28
|
+
totalAmount: '1299.90',
|
|
29
|
+
totalDiscountAmount: '100.00',
|
|
30
|
+
voucherCode: 'WELCOME',
|
|
31
|
+
currency: 'TRY',
|
|
32
|
+
items: [{ title: 'Spring Sneaker', currency: 'TRY', totalAmount: '649.95' }],
|
|
33
|
+
...overrides
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('page context as an implicit binding root', () => {
|
|
38
|
+
it('resolves a basket binding on a section that has NO data source', () => {
|
|
39
|
+
const result = resolveBlockBindings({
|
|
40
|
+
block: textBlock({
|
|
41
|
+
value: 'Total',
|
|
42
|
+
properties: { dataBinding: 'basket.totalAmount' }
|
|
43
|
+
}),
|
|
44
|
+
pageContext: basketContext()
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.value).toBe('1.299,90 TL');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps the authored value when the path is not on the page', () => {
|
|
51
|
+
const result = resolveBlockBindings({
|
|
52
|
+
block: textBlock({
|
|
53
|
+
value: 'Fallback',
|
|
54
|
+
properties: { dataBinding: 'basket.shippingAmount' }
|
|
55
|
+
}),
|
|
56
|
+
pageContext: basketContext()
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.value).toBe('Fallback');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves a token template mixed with literal text', () => {
|
|
63
|
+
const result = resolveBlockBindings({
|
|
64
|
+
block: textBlock({
|
|
65
|
+
value: '',
|
|
66
|
+
properties: {
|
|
67
|
+
dataBinding: '{{basket.totalQuantity}} items, {{basket.voucherCode}}'
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
pageContext: basketContext()
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.value).toBe('2 items, WELCOME');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not let a source-less iterator read the page context', () => {
|
|
77
|
+
// The fallback is opt-in for value bindings only. A repeat with no data
|
|
78
|
+
// path must not start iterating whatever the page happens to publish.
|
|
79
|
+
const iterator = buildIteratorBlock({
|
|
80
|
+
iteratorBlock: {
|
|
81
|
+
id: 'group',
|
|
82
|
+
type: 'group',
|
|
83
|
+
label: 'Rows',
|
|
84
|
+
value: undefined,
|
|
85
|
+
properties: {},
|
|
86
|
+
styles: {},
|
|
87
|
+
order: 0,
|
|
88
|
+
hidden: false,
|
|
89
|
+
isIterator: true,
|
|
90
|
+
blocks: []
|
|
91
|
+
} as any,
|
|
92
|
+
pageContext: { items: [{ title: 'A' }, { title: 'B' }] }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(iterator.blocks).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('money formatting for page-scoped bindings', () => {
|
|
100
|
+
it('formats with the currency the basket publishes', () => {
|
|
101
|
+
const result = resolveBlockBindings({
|
|
102
|
+
block: textBlock({ properties: { dataBinding: 'basket.totalAmount' } }),
|
|
103
|
+
pageContext: basketContext({ currency: 'EUR', items: [] })
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.value).toBe('1.299,90 EUR');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('falls back to the storefront currency for an empty basket', () => {
|
|
110
|
+
const result = resolveBlockBindings({
|
|
111
|
+
block: textBlock({ properties: { dataBinding: 'basket.totalAmount' } }),
|
|
112
|
+
pageContext: basketContext({ currency: '', items: [], isEmpty: true }),
|
|
113
|
+
currency: 'GBP'
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.value).toBe('1.299,90 GBP');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('leaves a non-money field alone', () => {
|
|
120
|
+
const result = resolveBlockBindings({
|
|
121
|
+
block: textBlock({ properties: { dataBinding: 'basket.voucherCode' } }),
|
|
122
|
+
pageContext: basketContext()
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.value).toBe('WELCOME');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('formats a money field on any page root, not just the basket', () => {
|
|
129
|
+
const result = resolveBlockBindings({
|
|
130
|
+
block: textBlock({ properties: { dataBinding: 'product.price' } }),
|
|
131
|
+
pageContext: { product: { price: '499.5', currency: 'TRY' } }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.value).toBe('499,50 TL');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { FieldSemantic, readFieldSemantic } from './field-semantics';
|
|
2
|
+
|
|
1
3
|
type DiscoveryValueType =
|
|
2
4
|
| 'string'
|
|
3
5
|
| 'number'
|
|
@@ -10,6 +12,12 @@ export interface PageContextDiscoveryEntry {
|
|
|
10
12
|
path: string;
|
|
11
13
|
kind: 'scalar' | 'array';
|
|
12
14
|
valueType: DiscoveryValueType;
|
|
15
|
+
/**
|
|
16
|
+
* What the field means, which is what a designer needs and what `valueType`
|
|
17
|
+
* cannot tell them: commerce publishes money, an image URL and a product
|
|
18
|
+
* title as three indistinguishable strings.
|
|
19
|
+
*/
|
|
20
|
+
semantic: FieldSemantic;
|
|
13
21
|
sample?: string;
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -113,6 +121,7 @@ export const createPageContextDiscovery = (
|
|
|
113
121
|
path,
|
|
114
122
|
kind: 'array',
|
|
115
123
|
valueType: 'array',
|
|
124
|
+
semantic: readFieldSemantic(path, 'array'),
|
|
116
125
|
sample: formatSample(value)
|
|
117
126
|
});
|
|
118
127
|
|
|
@@ -136,10 +145,13 @@ export const createPageContextDiscovery = (
|
|
|
136
145
|
return;
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
const valueType = getValueType(value);
|
|
149
|
+
|
|
139
150
|
pushEntry({
|
|
140
151
|
path,
|
|
141
152
|
kind: 'scalar',
|
|
142
|
-
valueType
|
|
153
|
+
valueType,
|
|
154
|
+
semantic: readFieldSemantic(path, valueType),
|
|
143
155
|
sample: formatSample(value)
|
|
144
156
|
});
|
|
145
157
|
};
|