@teleporthq/teleport-plugin-next-data-source 0.43.57 → 0.43.60
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/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
- package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
- package/__tests__/ecommerce-product-discounts.test.ts +196 -0
- package/__tests__/ecommerce-product-model.test.ts +179 -0
- package/__tests__/infinite-scroll.test.ts +143 -0
- package/__tests__/numbered-pagination.test.ts +113 -0
- package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
- package/__tests__/page-url-sync.test.ts +208 -0
- package/__tests__/product-price-sort.ts +313 -0
- package/__tests__/search-url-sync.test.ts +7 -3
- package/dist/cjs/array-mapper-pagination.d.ts +9 -0
- package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
- package/dist/cjs/array-mapper-pagination.js.map +1 -1
- package/dist/cjs/count-effect.d.ts +42 -0
- package/dist/cjs/count-effect.d.ts.map +1 -0
- package/dist/cjs/count-effect.js +81 -0
- package/dist/cjs/count-effect.js.map +1 -0
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +2 -1
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +3 -2
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/infinite-scroll.d.ts +65 -0
- package/dist/cjs/infinite-scroll.d.ts.map +1 -0
- package/dist/cjs/infinite-scroll.js +190 -0
- package/dist/cjs/infinite-scroll.js.map +1 -0
- package/dist/cjs/numbered-pagination.d.ts +42 -0
- package/dist/cjs/numbered-pagination.d.ts.map +1 -0
- package/dist/cjs/numbered-pagination.js +167 -0
- package/dist/cjs/numbered-pagination.js.map +1 -0
- package/dist/cjs/page-reset.d.ts +42 -0
- package/dist/cjs/page-reset.d.ts.map +1 -0
- package/dist/cjs/page-reset.js +116 -0
- package/dist/cjs/page-reset.js.map +1 -0
- package/dist/cjs/page-url-sync.d.ts +42 -0
- package/dist/cjs/page-url-sync.d.ts.map +1 -0
- package/dist/cjs/page-url-sync.js +180 -0
- package/dist/cjs/page-url-sync.js.map +1 -0
- package/dist/cjs/pagination-controls.d.ts +55 -0
- package/dist/cjs/pagination-controls.d.ts.map +1 -0
- package/dist/cjs/pagination-controls.js +159 -0
- package/dist/cjs/pagination-controls.js.map +1 -0
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +506 -160
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/product-price-sort.d.ts +116 -0
- package/dist/cjs/product-price-sort.d.ts.map +1 -0
- package/dist/cjs/product-price-sort.js +169 -0
- package/dist/cjs/product-price-sort.js.map +1 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +2 -1
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +2 -1
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +17 -1
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
- package/dist/cjs/transformations/shared-utils.js +1 -1
- package/dist/cjs/transformations/shared-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/array-mapper-pagination.d.ts +9 -0
- package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
- package/dist/esm/array-mapper-pagination.js.map +1 -1
- package/dist/esm/count-effect.d.ts +42 -0
- package/dist/esm/count-effect.d.ts.map +1 -0
- package/dist/esm/count-effect.js +53 -0
- package/dist/esm/count-effect.js.map +1 -0
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +2 -1
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +3 -2
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/infinite-scroll.d.ts +65 -0
- package/dist/esm/infinite-scroll.d.ts.map +1 -0
- package/dist/esm/infinite-scroll.js +159 -0
- package/dist/esm/infinite-scroll.js.map +1 -0
- package/dist/esm/numbered-pagination.d.ts +42 -0
- package/dist/esm/numbered-pagination.d.ts.map +1 -0
- package/dist/esm/numbered-pagination.js +138 -0
- package/dist/esm/numbered-pagination.js.map +1 -0
- package/dist/esm/page-reset.d.ts +42 -0
- package/dist/esm/page-reset.d.ts.map +1 -0
- package/dist/esm/page-reset.js +87 -0
- package/dist/esm/page-reset.js.map +1 -0
- package/dist/esm/page-url-sync.d.ts +42 -0
- package/dist/esm/page-url-sync.d.ts.map +1 -0
- package/dist/esm/page-url-sync.js +152 -0
- package/dist/esm/page-url-sync.js.map +1 -0
- package/dist/esm/pagination-controls.d.ts +55 -0
- package/dist/esm/pagination-controls.d.ts.map +1 -0
- package/dist/esm/pagination-controls.js +126 -0
- package/dist/esm/pagination-controls.js.map +1 -0
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +507 -161
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/product-price-sort.d.ts +116 -0
- package/dist/esm/product-price-sort.d.ts.map +1 -0
- package/dist/esm/product-price-sort.js +160 -0
- package/dist/esm/product-price-sort.js.map +1 -0
- package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +3 -2
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +2 -1
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +17 -1
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
- package/dist/esm/transformations/shared-utils.js +1 -1
- package/dist/esm/transformations/shared-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/array-mapper-pagination.ts +10 -0
- package/src/count-effect.ts +137 -0
- package/src/fetchers/postgresql.ts +52 -13
- package/src/fetchers/teleport.ts +53 -13
- package/src/infinite-scroll.ts +401 -0
- package/src/numbered-pagination.ts +292 -0
- package/src/page-reset.ts +162 -0
- package/src/page-url-sync.ts +241 -0
- package/src/pagination-controls.ts +176 -0
- package/src/pagination-plugin.ts +755 -346
- package/src/product-price-sort.ts +204 -0
- package/src/transformations/ecommerce-product.ts +141 -10
- package/src/transformations/index.ts +19 -1
- package/src/transformations/shared-utils.ts +14 -0
- package/dist/cjs/array-mapper-registry.d.ts +0 -85
- package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
- package/dist/cjs/array-mapper-registry.js +0 -296
- package/dist/cjs/array-mapper-registry.js.map +0 -1
- package/dist/cjs/pagination-with-count.d.ts +0 -6
- package/dist/cjs/pagination-with-count.d.ts.map +0 -1
- package/dist/cjs/pagination-with-count.js +0 -63
- package/dist/cjs/pagination-with-count.js.map +0 -1
- package/dist/esm/array-mapper-registry.d.ts +0 -85
- package/dist/esm/array-mapper-registry.d.ts.map +0 -1
- package/dist/esm/array-mapper-registry.js +0 -292
- package/dist/esm/array-mapper-registry.js.map +0 -1
- package/dist/esm/pagination-with-count.d.ts +0 -6
- package/dist/esm/pagination-with-count.d.ts.map +0 -1
- package/dist/esm/pagination-with-count.js +0 -34
- package/dist/esm/pagination-with-count.js.map +0 -1
- package/src/array-mapper-registry.ts +0 -416
- package/src/pagination-with-count.ts +0 -89
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as types from '@babel/types'
|
|
2
|
+
import { ChunkType, FileType, ChunkDefinition } from '@teleporthq/teleport-types'
|
|
3
|
+
import { PAGINATION_CONTROL_ATTR } from '../../src/pagination-controls'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A component shell carrying a `DataProvider` and the pagination controls a
|
|
7
|
+
* builder would have authored, so the wiring steps of the plugin have something
|
|
8
|
+
* real to find.
|
|
9
|
+
*
|
|
10
|
+
* `__tests__/_helpers` is excluded from the jest test match, so this file is a
|
|
11
|
+
* fixture rather than a suite.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// tslint:disable:no-any
|
|
15
|
+
|
|
16
|
+
interface ControlOptions {
|
|
17
|
+
/** Which `data-tq-pagination-control` markers to author. */
|
|
18
|
+
controls: string[]
|
|
19
|
+
/** Author Previous/Next with only the legacy class names, no markers. */
|
|
20
|
+
legacyOnly?: boolean
|
|
21
|
+
/** Number of DataProvider + widget pairs to emit (order-based zip coverage). */
|
|
22
|
+
mappers?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const control = (marker: string, children: types.JSXElement['children'] = []): types.JSXElement =>
|
|
26
|
+
types.jsxElement(
|
|
27
|
+
types.jsxOpeningElement(
|
|
28
|
+
types.jsxIdentifier('div'),
|
|
29
|
+
[
|
|
30
|
+
types.jsxAttribute(
|
|
31
|
+
types.jsxIdentifier('className'),
|
|
32
|
+
types.stringLiteral(`thq-${marker}-elm`)
|
|
33
|
+
),
|
|
34
|
+
types.jsxAttribute(
|
|
35
|
+
types.jsxIdentifier(PAGINATION_CONTROL_ATTR),
|
|
36
|
+
types.stringLiteral(marker)
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
false
|
|
40
|
+
),
|
|
41
|
+
types.jsxClosingElement(types.jsxIdentifier('div')),
|
|
42
|
+
children,
|
|
43
|
+
false
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const legacyControl = (direction: 'previous' | 'next'): types.JSXElement =>
|
|
47
|
+
types.jsxElement(
|
|
48
|
+
types.jsxOpeningElement(
|
|
49
|
+
types.jsxIdentifier('div'),
|
|
50
|
+
[
|
|
51
|
+
types.jsxAttribute(
|
|
52
|
+
types.jsxIdentifier('className'),
|
|
53
|
+
types.stringLiteral(`products-list-thq-${direction}-elm`)
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
false
|
|
57
|
+
),
|
|
58
|
+
types.jsxClosingElement(types.jsxIdentifier('div')),
|
|
59
|
+
[types.jsxText(direction === 'previous' ? 'Previous' : 'Next')],
|
|
60
|
+
false
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const buildWidget = (options: ControlOptions): types.JSXElement => {
|
|
64
|
+
const children: types.JSXElement[] = []
|
|
65
|
+
for (const marker of options.controls) {
|
|
66
|
+
if (marker === 'pages') {
|
|
67
|
+
// The container holds ONE template button, which the generator repeats.
|
|
68
|
+
children.push(control('pages', [control('page', [types.jsxText('1')])]))
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (options.legacyOnly && (marker === 'previous' || marker === 'next')) {
|
|
72
|
+
children.push(legacyControl(marker))
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
children.push(control(marker))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return types.jsxElement(
|
|
79
|
+
types.jsxOpeningElement(
|
|
80
|
+
types.jsxIdentifier('div'),
|
|
81
|
+
[
|
|
82
|
+
types.jsxAttribute(
|
|
83
|
+
types.jsxIdentifier('className'),
|
|
84
|
+
types.stringLiteral('products-list-cms-pagination-node')
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
false
|
|
88
|
+
),
|
|
89
|
+
types.jsxClosingElement(types.jsxIdentifier('div')),
|
|
90
|
+
children,
|
|
91
|
+
false
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Mirrors the real emitted shape: a self-closing `<DataProvider>` whose
|
|
96
|
+
// `renderSuccess` returns a `<Repeater renderItem={row => …}>`. The plugin only
|
|
97
|
+
// treats a provider as an array mapper when it can read that `renderItem`
|
|
98
|
+
// parameter name, so the fixture must carry one.
|
|
99
|
+
const buildDataProvider = (name: string, rowIdentifier: string): types.JSXElement =>
|
|
100
|
+
types.jsxElement(
|
|
101
|
+
types.jsxOpeningElement(
|
|
102
|
+
types.jsxIdentifier('DataProvider'),
|
|
103
|
+
[
|
|
104
|
+
types.jsxAttribute(
|
|
105
|
+
types.jsxIdentifier('name'),
|
|
106
|
+
types.jsxExpressionContainer(types.stringLiteral(name))
|
|
107
|
+
),
|
|
108
|
+
types.jsxAttribute(
|
|
109
|
+
types.jsxIdentifier('renderSuccess'),
|
|
110
|
+
types.jsxExpressionContainer(
|
|
111
|
+
types.arrowFunctionExpression(
|
|
112
|
+
[types.identifier('items')],
|
|
113
|
+
types.jsxElement(
|
|
114
|
+
types.jsxOpeningElement(
|
|
115
|
+
types.jsxIdentifier('Repeater'),
|
|
116
|
+
[
|
|
117
|
+
types.jsxAttribute(
|
|
118
|
+
types.jsxIdentifier('renderItem'),
|
|
119
|
+
types.jsxExpressionContainer(
|
|
120
|
+
types.arrowFunctionExpression(
|
|
121
|
+
[types.identifier(rowIdentifier)],
|
|
122
|
+
types.jsxElement(
|
|
123
|
+
types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
|
|
124
|
+
null,
|
|
125
|
+
[],
|
|
126
|
+
true
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
true
|
|
133
|
+
),
|
|
134
|
+
null,
|
|
135
|
+
[],
|
|
136
|
+
true
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
),
|
|
141
|
+
],
|
|
142
|
+
true
|
|
143
|
+
),
|
|
144
|
+
null,
|
|
145
|
+
[],
|
|
146
|
+
true
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
export const makeWidgetComponentChunk = (options: ControlOptions): ChunkDefinition => {
|
|
150
|
+
const sections: types.JSXElement[] = []
|
|
151
|
+
const mappers = options.mappers ?? 1
|
|
152
|
+
for (let i = 0; i < mappers; i += 1) {
|
|
153
|
+
const identifier = `items${i === 0 ? '' : i}`
|
|
154
|
+
sections.push(buildDataProvider(identifier, `${identifier}_row`))
|
|
155
|
+
sections.push(buildWidget(options))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const root = types.jsxElement(
|
|
159
|
+
types.jsxOpeningElement(types.jsxIdentifier('div'), [], false),
|
|
160
|
+
types.jsxClosingElement(types.jsxIdentifier('div')),
|
|
161
|
+
sections,
|
|
162
|
+
false
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
name: 'jsx-component',
|
|
167
|
+
type: ChunkType.AST,
|
|
168
|
+
fileType: FileType.JS,
|
|
169
|
+
linkAfter: [],
|
|
170
|
+
content: types.variableDeclaration('const', [
|
|
171
|
+
types.variableDeclarator(
|
|
172
|
+
types.identifier('TestComponent'),
|
|
173
|
+
types.arrowFunctionExpression(
|
|
174
|
+
[types.identifier('props')],
|
|
175
|
+
types.blockStatement([types.returnStatement(root)])
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
]),
|
|
179
|
+
meta: {},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface RepeaterOptions {
|
|
184
|
+
paginated?: boolean
|
|
185
|
+
searchEnabled?: boolean
|
|
186
|
+
paginationMode?: 'buttons' | 'numbered'
|
|
187
|
+
infiniteScroll?: boolean
|
|
188
|
+
infiniteScrollLoadMore?: boolean
|
|
189
|
+
perPage?: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'items'): any => ({
|
|
193
|
+
type: 'data-source-list',
|
|
194
|
+
content: {
|
|
195
|
+
renderPropIdentifier: identifier,
|
|
196
|
+
resourceDefinition: {
|
|
197
|
+
dataSourceId: 'ds1',
|
|
198
|
+
tableName: 'products',
|
|
199
|
+
dataSourceType: 'postgresql',
|
|
200
|
+
},
|
|
201
|
+
resource: { params: { queryColumns: { content: ['name'] } } },
|
|
202
|
+
nodes: {
|
|
203
|
+
success: {
|
|
204
|
+
type: 'cms-list-repeater',
|
|
205
|
+
content: {
|
|
206
|
+
renderPropIdentifier: `${identifier}_row`,
|
|
207
|
+
paginated: options.paginated !== false,
|
|
208
|
+
perPage: options.perPage ?? 20,
|
|
209
|
+
searchEnabled: !!options.searchEnabled,
|
|
210
|
+
searchDebounce: 300,
|
|
211
|
+
...(options.paginationMode ? { paginationMode: options.paginationMode } : {}),
|
|
212
|
+
...(options.infiniteScroll ? { infiniteScroll: true } : {}),
|
|
213
|
+
...(options.infiniteScrollLoadMore ? { infiniteScrollLoadMore: true } : {}),
|
|
214
|
+
nodes: { list: { type: 'element', content: { elementType: 'div' } } },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
/** Wraps several data-source-lists in one element node, as a page would. */
|
|
222
|
+
export const makeMultiMapperUidl = (repeaters: RepeaterOptions[]): any => ({
|
|
223
|
+
type: 'element',
|
|
224
|
+
content: {
|
|
225
|
+
elementType: 'container',
|
|
226
|
+
children: repeaters.map((options, index) =>
|
|
227
|
+
makeUidlNode(options, `items${index === 0 ? '' : index}`)
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
import { buildProductTransformOptions } from '../src/transformations'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* With backorders allowed (or stock management disabled) the merchant sells
|
|
7
|
+
* past zero, so NOTHING in the storefront may present a product or a variant
|
|
8
|
+
* combination as sold out:
|
|
9
|
+
*
|
|
10
|
+
* - `outOfStock` stays 'false' even at quantity <= 0;
|
|
11
|
+
* - a zero-stock combination is NOT dead, IS default-selectable, and keeps
|
|
12
|
+
* `hasPurchasableVariant: 'true'` / a non-empty `defaultVariantId`;
|
|
13
|
+
* - a combination that does not exist at all is STILL unavailable — there
|
|
14
|
+
* is no variant row to backorder.
|
|
15
|
+
*
|
|
16
|
+
* The flag is baked at export time via
|
|
17
|
+
* `EcommerceProductTransformOptions.allowBackorders`, resolved in ONE place
|
|
18
|
+
* (`buildProductTransformOptions`) as: stock management off OR
|
|
19
|
+
* `stockManagementConfig.allowBackorders` on.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const evalBuildProduct = (
|
|
23
|
+
allowBackorders: boolean
|
|
24
|
+
): ((r: unknown, o?: unknown) => Record<string, unknown>) => {
|
|
25
|
+
const code =
|
|
26
|
+
generateSharedTransformationCode() +
|
|
27
|
+
'\n' +
|
|
28
|
+
generateEcommerceProductTransformationCode({ allowBackorders })
|
|
29
|
+
return new Function(code + '\nreturn buildEcommerceProduct;')() as (
|
|
30
|
+
r: unknown,
|
|
31
|
+
o?: unknown
|
|
32
|
+
) => Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Axis = { key: string; values: Array<Record<string, unknown>> }
|
|
36
|
+
|
|
37
|
+
const SIZE_AXIS = JSON.stringify([
|
|
38
|
+
{
|
|
39
|
+
key: 'size',
|
|
40
|
+
name: 'Size',
|
|
41
|
+
type: 'text',
|
|
42
|
+
values: [
|
|
43
|
+
{ value: 'xs', label: 'XS' },
|
|
44
|
+
{ value: 's', label: 'S' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
const variantProduct = (overrides: Record<string, unknown> = {}) => ({
|
|
50
|
+
id: 'p1',
|
|
51
|
+
name: 'Tee',
|
|
52
|
+
slug: 'tee',
|
|
53
|
+
price: 20,
|
|
54
|
+
currency: 'USD',
|
|
55
|
+
variant_options: SIZE_AXIS,
|
|
56
|
+
...overrides,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const values = (product: Record<string, unknown>): Array<Record<string, unknown>> =>
|
|
60
|
+
(product.variantOptions as Axis[])[0].values
|
|
61
|
+
|
|
62
|
+
describe('ecommerce product transform — allowBackorders', () => {
|
|
63
|
+
const withBackorders = evalBuildProduct(true)
|
|
64
|
+
const withoutBackorders = evalBuildProduct(false)
|
|
65
|
+
|
|
66
|
+
it('a zero-quantity FLAT product is never out of stock with backorders', () => {
|
|
67
|
+
const record = { id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: 0 }
|
|
68
|
+
expect(withBackorders(record).outOfStock).toBe('false')
|
|
69
|
+
expect(withoutBackorders(record).outOfStock).toBe('true')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('a NEGATIVE quantity (already oversold) is still not out of stock', () => {
|
|
73
|
+
const record = { id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: -3 }
|
|
74
|
+
expect(withBackorders(record).outOfStock).toBe('false')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('every combination sold out: values stay alive and purchasable with backorders', () => {
|
|
78
|
+
const options = {
|
|
79
|
+
variantsByProductId: {
|
|
80
|
+
p1: [
|
|
81
|
+
{ id: 'v-xs', options: { size: 'xs' }, quantity: 0 },
|
|
82
|
+
{ id: 'v-s', options: { size: 's' }, quantity: 0 },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
const product = withBackorders(variantProduct(), options)
|
|
87
|
+
expect(values(product).every((v) => v.isDead === 'false')).toBe(true)
|
|
88
|
+
expect(product.hasPurchasableVariant).toBe('true')
|
|
89
|
+
// The first covering combination becomes the default selection again.
|
|
90
|
+
expect(product.defaultVariantId).toBe('v-xs')
|
|
91
|
+
|
|
92
|
+
// The strict build keeps its sold-out answer (regression guard for the
|
|
93
|
+
// flag's default).
|
|
94
|
+
const strict = withoutBackorders(variantProduct(), options)
|
|
95
|
+
expect(strict.hasPurchasableVariant).toBe('false')
|
|
96
|
+
expect(strict.defaultVariantId).toBe('')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('a value with NO combination behind it stays dead even with backorders', () => {
|
|
100
|
+
const product = withBackorders(variantProduct(), {
|
|
101
|
+
variantsByProductId: { p1: [{ id: 'v-xs', options: { size: 'xs' }, quantity: 0 }] },
|
|
102
|
+
})
|
|
103
|
+
expect(values(product)[0].isDead).toBe('false')
|
|
104
|
+
// 's' has no variant row — nothing exists to backorder.
|
|
105
|
+
expect(values(product)[1].isDead).toBe('true')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('looked up and there are NO combinations at all: still not purchasable', () => {
|
|
109
|
+
const product = withBackorders(variantProduct(), { variantsByProductId: {} })
|
|
110
|
+
expect(values(product).every((v) => v.isDead === 'true')).toBe(true)
|
|
111
|
+
expect(product.hasPurchasableVariant).toBe('false')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('omitting the option keeps the strict pre-flag behaviour', () => {
|
|
115
|
+
const code =
|
|
116
|
+
generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
|
|
117
|
+
const build = new Function(code + '\nreturn buildEcommerceProduct;')() as (
|
|
118
|
+
r: unknown
|
|
119
|
+
) => Record<string, unknown>
|
|
120
|
+
expect(
|
|
121
|
+
build({ id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: 0 })
|
|
122
|
+
.outOfStock
|
|
123
|
+
).toBe('true')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('buildProductTransformOptions — allowBackorders resolution', () => {
|
|
128
|
+
const settings = (stockManagement: boolean, allowBackorders?: boolean) =>
|
|
129
|
+
({
|
|
130
|
+
stockManagement,
|
|
131
|
+
stockManagementConfig:
|
|
132
|
+
allowBackorders === undefined ? null : { allowBackorders, lowStockThreshold: 5 },
|
|
133
|
+
} as never)
|
|
134
|
+
|
|
135
|
+
it('stock management ON + backorders ON → true', () => {
|
|
136
|
+
expect(
|
|
137
|
+
buildProductTransformOptions({ ecommerceSettings: settings(true, true) }).allowBackorders
|
|
138
|
+
).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('stock management ON + backorders OFF → false', () => {
|
|
142
|
+
expect(
|
|
143
|
+
buildProductTransformOptions({ ecommerceSettings: settings(true, false) }).allowBackorders
|
|
144
|
+
).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('stock management OFF → true regardless of the config block', () => {
|
|
148
|
+
expect(
|
|
149
|
+
buildProductTransformOptions({ ecommerceSettings: settings(false) }).allowBackorders
|
|
150
|
+
).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('no e-commerce settings at all → false (strict default)', () => {
|
|
154
|
+
expect(buildProductTransformOptions({}).allowBackorders).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-product discounts in the GENERATED storefront transform — the third
|
|
6
|
+
* mirror of the rule that also lives in teleport-gui as
|
|
7
|
+
* `features/e-commerce/utils/product-discounts.ts` (editor/canvas) and
|
|
8
|
+
* `.../builders/ecommerce/product-discount-script.ts` (the baked add-to-cart
|
|
9
|
+
* script). The GUI's `product-discount-parity.spec.ts` pins those two together;
|
|
10
|
+
* this file is what keeps THIS copy honest.
|
|
11
|
+
*
|
|
12
|
+
* The two invariants under test: a discount comes off the NET price BEFORE tax,
|
|
13
|
+
* and the stored `price` never moves — every write path re-reads the raw row.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const evalBuildProduct = (
|
|
17
|
+
storefrontTaxRate = 0
|
|
18
|
+
): ((record: unknown) => Record<string, unknown>) => {
|
|
19
|
+
const code =
|
|
20
|
+
generateSharedTransformationCode() +
|
|
21
|
+
'\n' +
|
|
22
|
+
generateEcommerceProductTransformationCode({ storefrontTaxRate })
|
|
23
|
+
const fn = new Function(code + '\nreturn buildEcommerceProduct;')
|
|
24
|
+
return fn() as (record: unknown) => Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FLAT_PRODUCT = { id: 'p1', name: 'P', slug: 'p', price: '100.00', currency: 'USD' }
|
|
28
|
+
|
|
29
|
+
const discountsColumn = (
|
|
30
|
+
over: Partial<{
|
|
31
|
+
type: string
|
|
32
|
+
value: number
|
|
33
|
+
startsAt: string | null
|
|
34
|
+
endsAt: string | null
|
|
35
|
+
}> = {}
|
|
36
|
+
): string =>
|
|
37
|
+
JSON.stringify([
|
|
38
|
+
{ id: 'd1', type: 'percentage', value: 10, startsAt: null, endsAt: null, ...over },
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
describe('ecommerce product transform — per-product discounts', () => {
|
|
42
|
+
it('leaves `price` alone and marks the DISPLAY price down', () => {
|
|
43
|
+
const product = evalBuildProduct()({ ...FLAT_PRODUCT, discounts: discountsColumn() })
|
|
44
|
+
expect(product.price).toBe(100)
|
|
45
|
+
expect(product.displayPrice).toBe('90.00')
|
|
46
|
+
expect(product.hasDiscount).toBe('true')
|
|
47
|
+
expect(product.originalDisplayPrice).toBe('100.00')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('takes the discount off the NET price, then adds tax on top of that', () => {
|
|
51
|
+
const product = evalBuildProduct(19)({ ...FLAT_PRODUCT, discounts: discountsColumn() })
|
|
52
|
+
// 100 → 90 net → 90 * 1.19 = 107.10 gross. Taxing first would give 119 − 10%.
|
|
53
|
+
expect(product.displayPrice).toBe('107.10')
|
|
54
|
+
expect(product.originalDisplayPrice).toBe('119.00')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('emits the badge copy for both discount types', () => {
|
|
58
|
+
const percentage = evalBuildProduct()({ ...FLAT_PRODUCT, discounts: discountsColumn() })
|
|
59
|
+
expect(percentage.discountLabel).toBe('10% off')
|
|
60
|
+
|
|
61
|
+
const fixed = evalBuildProduct()({
|
|
62
|
+
...FLAT_PRODUCT,
|
|
63
|
+
discounts: discountsColumn({ type: 'fixed', value: 15 }),
|
|
64
|
+
})
|
|
65
|
+
expect(fixed.discountLabel).toBe('$15 off')
|
|
66
|
+
expect(fixed.displayPrice).toBe('85.00')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('puts the currency symbol where the currency wants it', () => {
|
|
70
|
+
const product = evalBuildProduct()({
|
|
71
|
+
...FLAT_PRODUCT,
|
|
72
|
+
currency: 'SEK',
|
|
73
|
+
discounts: discountsColumn({ type: 'fixed', value: 20 }),
|
|
74
|
+
})
|
|
75
|
+
expect(String(product.discountLabel).endsWith(' off')).toBe(true)
|
|
76
|
+
expect(String(product.discountLabel)).toContain('20')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reports no discount — and no struck price — when nothing is scheduled', () => {
|
|
80
|
+
const product = evalBuildProduct()(FLAT_PRODUCT)
|
|
81
|
+
expect(product.hasDiscount).toBe('false')
|
|
82
|
+
expect(product.discountLabel).toBe('')
|
|
83
|
+
expect(product.originalDisplayPrice).toBe('')
|
|
84
|
+
expect(product.displayPrice).toBe('100.00')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('honours the half-open window: live at the start instant, over at the end', () => {
|
|
88
|
+
const now = Date.now()
|
|
89
|
+
const live = evalBuildProduct()({
|
|
90
|
+
...FLAT_PRODUCT,
|
|
91
|
+
discounts: JSON.stringify([
|
|
92
|
+
{
|
|
93
|
+
id: 'd1',
|
|
94
|
+
type: 'percentage',
|
|
95
|
+
value: 10,
|
|
96
|
+
startsAt: new Date(now - 1000).toISOString(),
|
|
97
|
+
endsAt: new Date(now + 60000).toISOString(),
|
|
98
|
+
},
|
|
99
|
+
]),
|
|
100
|
+
})
|
|
101
|
+
expect(live.hasDiscount).toBe('true')
|
|
102
|
+
|
|
103
|
+
const expired = evalBuildProduct()({
|
|
104
|
+
...FLAT_PRODUCT,
|
|
105
|
+
discounts: JSON.stringify([
|
|
106
|
+
{
|
|
107
|
+
id: 'd1',
|
|
108
|
+
type: 'percentage',
|
|
109
|
+
value: 10,
|
|
110
|
+
startsAt: new Date(now - 60000).toISOString(),
|
|
111
|
+
endsAt: new Date(now - 1000).toISOString(),
|
|
112
|
+
},
|
|
113
|
+
]),
|
|
114
|
+
})
|
|
115
|
+
expect(expired.hasDiscount).toBe('false')
|
|
116
|
+
expect(expired.displayPrice).toBe('100.00')
|
|
117
|
+
|
|
118
|
+
const scheduled = evalBuildProduct()({
|
|
119
|
+
...FLAT_PRODUCT,
|
|
120
|
+
discounts: JSON.stringify([
|
|
121
|
+
{ id: 'd1', type: 'percentage', value: 10, startsAt: new Date(now + 60000).toISOString() },
|
|
122
|
+
]),
|
|
123
|
+
})
|
|
124
|
+
expect(scheduled.hasDiscount).toBe('false')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('never goes below zero, and never renders a negative price', () => {
|
|
128
|
+
const product = evalBuildProduct()({
|
|
129
|
+
...FLAT_PRODUCT,
|
|
130
|
+
discounts: discountsColumn({ type: 'fixed', value: 500 }),
|
|
131
|
+
})
|
|
132
|
+
expect(product.displayPrice).toBe('0.00')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('falls back to the list price when the column is unusable', () => {
|
|
136
|
+
const cases = ['{not json', JSON.stringify({ type: 'percentage' }), '', null]
|
|
137
|
+
cases.forEach((discounts) => {
|
|
138
|
+
const product = evalBuildProduct()({ ...FLAT_PRODUCT, discounts })
|
|
139
|
+
expect(product.hasDiscount).toBe('false')
|
|
140
|
+
expect(product.displayPrice).toBe('100.00')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('discounts each variant from its OWN price, carrying the pre-discount twin', () => {
|
|
145
|
+
const record = {
|
|
146
|
+
...FLAT_PRODUCT,
|
|
147
|
+
discounts: discountsColumn({ value: 50 }),
|
|
148
|
+
variant_options: JSON.stringify([
|
|
149
|
+
{
|
|
150
|
+
key: 'size',
|
|
151
|
+
name: 'Size',
|
|
152
|
+
type: 'text',
|
|
153
|
+
values: [
|
|
154
|
+
{ value: 's', label: 'S' },
|
|
155
|
+
{ value: 'l', label: 'L' },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
]),
|
|
159
|
+
}
|
|
160
|
+
const build = new Function(
|
|
161
|
+
generateSharedTransformationCode() +
|
|
162
|
+
'\n' +
|
|
163
|
+
generateEcommerceProductTransformationCode({ storefrontTaxRate: 0 }) +
|
|
164
|
+
'\nreturn buildEcommerceProduct;'
|
|
165
|
+
)() as (row: unknown, variants: unknown) => Record<string, unknown>
|
|
166
|
+
|
|
167
|
+
const product = build(record, {
|
|
168
|
+
variantsByProductId: {
|
|
169
|
+
// An overriding combination, and one that INHERITS the product price.
|
|
170
|
+
p1: [
|
|
171
|
+
{ id: 'v-s', options: { size: 's' }, price: '40.00', quantity: 5 },
|
|
172
|
+
{ id: 'v-l', options: { size: 'l' }, price: null, quantity: 5 },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const variantsDisplay = JSON.parse(String(product.variantsDisplayJson)) as Array<{
|
|
178
|
+
id: string
|
|
179
|
+
price: number
|
|
180
|
+
originalPrice: number | null
|
|
181
|
+
}>
|
|
182
|
+
const small = variantsDisplay.find((entry) => entry.id === 'v-s')
|
|
183
|
+
const large = variantsDisplay.find((entry) => entry.id === 'v-l')
|
|
184
|
+
|
|
185
|
+
expect(small?.price).toBe(20)
|
|
186
|
+
expect(small?.originalPrice).toBe(40)
|
|
187
|
+
// The inheriting combination is discounted from the PRODUCT price, not left
|
|
188
|
+
// at the undiscounted base.
|
|
189
|
+
expect(large?.price).toBe(50)
|
|
190
|
+
expect(large?.originalPrice).toBe(100)
|
|
191
|
+
|
|
192
|
+
// The net twins stay untouched — every write path re-reads them.
|
|
193
|
+
const variantsJson = JSON.parse(String(product.variantsJson)) as Array<{ price: number | null }>
|
|
194
|
+
expect(variantsJson.map((entry) => entry.price)).toEqual([40, null])
|
|
195
|
+
})
|
|
196
|
+
})
|