@teleporthq/teleport-plugin-next-data-source 0.43.59 → 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.
Files changed (127) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
  2. package/__tests__/ecommerce-product-discounts.test.ts +196 -0
  3. package/__tests__/infinite-scroll.test.ts +143 -0
  4. package/__tests__/numbered-pagination.test.ts +113 -0
  5. package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
  6. package/__tests__/page-url-sync.test.ts +208 -0
  7. package/__tests__/product-price-sort.ts +313 -0
  8. package/__tests__/search-url-sync.test.ts +7 -3
  9. package/dist/cjs/array-mapper-pagination.d.ts +9 -0
  10. package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
  11. package/dist/cjs/array-mapper-pagination.js.map +1 -1
  12. package/dist/cjs/count-effect.d.ts +42 -0
  13. package/dist/cjs/count-effect.d.ts.map +1 -0
  14. package/dist/cjs/count-effect.js +81 -0
  15. package/dist/cjs/count-effect.js.map +1 -0
  16. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  17. package/dist/cjs/fetchers/postgresql.js +2 -1
  18. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  19. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/teleport.js +3 -2
  21. package/dist/cjs/fetchers/teleport.js.map +1 -1
  22. package/dist/cjs/infinite-scroll.d.ts +65 -0
  23. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  24. package/dist/cjs/infinite-scroll.js +190 -0
  25. package/dist/cjs/infinite-scroll.js.map +1 -0
  26. package/dist/cjs/numbered-pagination.d.ts +42 -0
  27. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  28. package/dist/cjs/numbered-pagination.js +167 -0
  29. package/dist/cjs/numbered-pagination.js.map +1 -0
  30. package/dist/cjs/page-reset.d.ts +42 -0
  31. package/dist/cjs/page-reset.d.ts.map +1 -0
  32. package/dist/cjs/page-reset.js +116 -0
  33. package/dist/cjs/page-reset.js.map +1 -0
  34. package/dist/cjs/page-url-sync.d.ts +42 -0
  35. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  36. package/dist/cjs/page-url-sync.js +180 -0
  37. package/dist/cjs/page-url-sync.js.map +1 -0
  38. package/dist/cjs/pagination-controls.d.ts +55 -0
  39. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  40. package/dist/cjs/pagination-controls.js +159 -0
  41. package/dist/cjs/pagination-controls.js.map +1 -0
  42. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  43. package/dist/cjs/pagination-plugin.js +506 -160
  44. package/dist/cjs/pagination-plugin.js.map +1 -1
  45. package/dist/cjs/product-price-sort.d.ts +116 -0
  46. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  47. package/dist/cjs/product-price-sort.js +169 -0
  48. package/dist/cjs/product-price-sort.js.map +1 -0
  49. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  50. package/dist/cjs/transformations/ecommerce-product.js +1 -1
  51. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  52. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  53. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  54. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  55. package/dist/esm/array-mapper-pagination.js.map +1 -1
  56. package/dist/esm/count-effect.d.ts +42 -0
  57. package/dist/esm/count-effect.d.ts.map +1 -0
  58. package/dist/esm/count-effect.js +53 -0
  59. package/dist/esm/count-effect.js.map +1 -0
  60. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  61. package/dist/esm/fetchers/postgresql.js +2 -1
  62. package/dist/esm/fetchers/postgresql.js.map +1 -1
  63. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  64. package/dist/esm/fetchers/teleport.js +3 -2
  65. package/dist/esm/fetchers/teleport.js.map +1 -1
  66. package/dist/esm/infinite-scroll.d.ts +65 -0
  67. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  68. package/dist/esm/infinite-scroll.js +159 -0
  69. package/dist/esm/infinite-scroll.js.map +1 -0
  70. package/dist/esm/numbered-pagination.d.ts +42 -0
  71. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  72. package/dist/esm/numbered-pagination.js +138 -0
  73. package/dist/esm/numbered-pagination.js.map +1 -0
  74. package/dist/esm/page-reset.d.ts +42 -0
  75. package/dist/esm/page-reset.d.ts.map +1 -0
  76. package/dist/esm/page-reset.js +87 -0
  77. package/dist/esm/page-reset.js.map +1 -0
  78. package/dist/esm/page-url-sync.d.ts +42 -0
  79. package/dist/esm/page-url-sync.d.ts.map +1 -0
  80. package/dist/esm/page-url-sync.js +152 -0
  81. package/dist/esm/page-url-sync.js.map +1 -0
  82. package/dist/esm/pagination-controls.d.ts +55 -0
  83. package/dist/esm/pagination-controls.d.ts.map +1 -0
  84. package/dist/esm/pagination-controls.js +126 -0
  85. package/dist/esm/pagination-controls.js.map +1 -0
  86. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  87. package/dist/esm/pagination-plugin.js +507 -161
  88. package/dist/esm/pagination-plugin.js.map +1 -1
  89. package/dist/esm/product-price-sort.d.ts +116 -0
  90. package/dist/esm/product-price-sort.d.ts.map +1 -0
  91. package/dist/esm/product-price-sort.js +160 -0
  92. package/dist/esm/product-price-sort.js.map +1 -0
  93. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  94. package/dist/esm/transformations/ecommerce-product.js +2 -2
  95. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  96. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +5 -5
  98. package/src/array-mapper-pagination.ts +10 -0
  99. package/src/count-effect.ts +137 -0
  100. package/src/fetchers/postgresql.ts +52 -13
  101. package/src/fetchers/teleport.ts +53 -13
  102. package/src/infinite-scroll.ts +401 -0
  103. package/src/numbered-pagination.ts +292 -0
  104. package/src/page-reset.ts +162 -0
  105. package/src/page-url-sync.ts +241 -0
  106. package/src/pagination-controls.ts +176 -0
  107. package/src/pagination-plugin.ts +755 -346
  108. package/src/product-price-sort.ts +204 -0
  109. package/src/transformations/ecommerce-product.ts +33 -7
  110. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  111. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  112. package/dist/cjs/array-mapper-registry.js +0 -296
  113. package/dist/cjs/array-mapper-registry.js.map +0 -1
  114. package/dist/cjs/pagination-with-count.d.ts +0 -6
  115. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  116. package/dist/cjs/pagination-with-count.js +0 -63
  117. package/dist/cjs/pagination-with-count.js.map +0 -1
  118. package/dist/esm/array-mapper-registry.d.ts +0 -85
  119. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  120. package/dist/esm/array-mapper-registry.js +0 -292
  121. package/dist/esm/array-mapper-registry.js.map +0 -1
  122. package/dist/esm/pagination-with-count.d.ts +0 -6
  123. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  124. package/dist/esm/pagination-with-count.js +0 -34
  125. package/dist/esm/pagination-with-count.js.map +0 -1
  126. package/src/array-mapper-registry.ts +0 -416
  127. 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,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
+ })
@@ -0,0 +1,143 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import { ComponentStructure } from '@teleporthq/teleport-types'
4
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
5
+ import {
6
+ makeMultiMapperUidl,
7
+ makeUidlNode,
8
+ makeWidgetComponentChunk,
9
+ type RepeaterOptions,
10
+ } from './_helpers/pagination-widget-fixture'
11
+
12
+ const runPlugin = async (
13
+ repeater: RepeaterOptions,
14
+ controls: string[] = ['previous', 'next']
15
+ ): Promise<string> => {
16
+ const chunk = makeWidgetComponentChunk({ controls })
17
+ const structure: ComponentStructure = {
18
+ uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
19
+ chunks: [chunk],
20
+ dependencies: {},
21
+ options: { dataSources: {}, extractedResources: {} },
22
+ } as never
23
+ await createNextArrayMapperPaginationPlugin()(structure)
24
+ return generator(chunk.content as types.Node).code
25
+ }
26
+
27
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
28
+
29
+ describe('pagination plugin — infinite scroll', () => {
30
+ it('accumulates pages inside fetchData and reports the end of the data', async () => {
31
+ const code = await runPlugin({ infiniteScroll: true })
32
+ const flat = collapse(code)
33
+
34
+ expect(flat).toContain('const ds_0_accumRef = useRef({ sig: "", pages: {} })')
35
+ expect(flat).toContain('const [ds_0_hasMoreFlag, setDs_0_hasMoreFlag] = useState(true)')
36
+ // Two independent end-of-data signals: a short page, and the counted total.
37
+ expect(flat).toContain(
38
+ 'const ds_0_hasMore = ds_0_hasMoreFlag && (ds_0_maxPages === 0 || ds_0_page < ds_0_maxPages)'
39
+ )
40
+
41
+ // Everything but the page identifies the QUERY: a filter, sort or search
42
+ // change must start a fresh list rather than interleave two result sets.
43
+ expect(flat).toContain('const sig = JSON.stringify({ ...params, page: 0 })')
44
+ expect(flat).toContain('if (ds_0_accumRef.current.sig !== sig)')
45
+ expect(flat).toContain('ds_0_accumRef.current = { sig, pages: {} }')
46
+ expect(flat).toContain('ds_0_accumRef.current.pages[page] = rows')
47
+ // A short page is the only end signal a source that cannot count gives.
48
+ expect(flat).toContain('setDs_0_hasMoreFlag(rows.length >= 20)')
49
+ // Numeric sort, so page 10 does not land between 1 and 2.
50
+ expect(flat).toContain('.map(Number).sort((a, b) => a - b).reduce(')
51
+ })
52
+
53
+ it('keeps the page out of the provider key and suppresses initialData', async () => {
54
+ const code = await runPlugin({ infiniteScroll: true })
55
+
56
+ // Remounting per appended page would throw away the accumulated rows...
57
+ expect(code).not.toContain('ds_0_page}`')
58
+ // ...and initialData would make DataProvider skip the fetch that fills the
59
+ // accumulator with page 1.
60
+ expect(code).not.toContain('initialData')
61
+ })
62
+
63
+ it('observes a sentinel after the list when there is no Load More button', async () => {
64
+ const code = await runPlugin({ infiniteScroll: true })
65
+ const flat = collapse(code)
66
+
67
+ expect(flat).toContain('const ds_0_sentinelRef = useRef(null)')
68
+ expect(flat).toContain(
69
+ '<div ref={ds_0_sentinelRef} aria-hidden="true" style={{ height: "1px" }} />'
70
+ )
71
+ expect(flat).toContain('new IntersectionObserver(')
72
+ expect(flat).toContain('typeof IntersectionObserver === "undefined"')
73
+ expect(flat).toContain('rootMargin: "200px"')
74
+ expect(flat).toContain('return () => observer.disconnect()')
75
+ expect(flat).toContain('}, [ds_0_hasMore, ds_0_maxPages])')
76
+ // ⛔ The next page comes from the updater's OWN argument. The effect is
77
+ // re-armed on hasMore/maxPages, neither of which changes on an append while
78
+ // the total is unknown — a captured page would still read 1 on the second
79
+ // intersection and the list would stop growing after one page.
80
+ expect(flat).toContain(
81
+ 'setDs_0_page(page => ds_0_maxPages > 0 && page >= ds_0_maxPages ? page : page + 1)'
82
+ )
83
+ })
84
+
85
+ it('wires the authored Load More button instead, with no observer', async () => {
86
+ const code = await runPlugin({ infiniteScroll: true, infiniteScrollLoadMore: true }, [
87
+ 'load-more',
88
+ ])
89
+ const flat = collapse(code)
90
+
91
+ expect(flat).toContain('className="thq-load-more-elm"')
92
+ expect(flat).toContain('type="button"')
93
+ expect(flat).toContain('disabled={!ds_0_hasMore}')
94
+ expect(flat).not.toContain('IntersectionObserver')
95
+ expect(flat).not.toContain('sentinelRef')
96
+ })
97
+
98
+ it('drops page controls left in the widget when the list appends', async () => {
99
+ const code = await runPlugin({ infiniteScroll: true, infiniteScrollLoadMore: true }, [
100
+ 'previous',
101
+ 'load-more',
102
+ 'next',
103
+ ])
104
+
105
+ // A visitor of an appending list never leaves page 1, so Previous/Next
106
+ // would be dead controls.
107
+ expect(code).not.toContain('thq-previous-elm')
108
+ expect(code).not.toContain('thq-next-elm')
109
+ expect(code).toContain('thq-load-more-elm')
110
+ })
111
+
112
+ it('strips a widget left behind on an auto-loading list', async () => {
113
+ // An older project (or an externally placed widget) can still carry
114
+ // Previous/Next after the author switches to infinite scroll. They would be
115
+ // dead controls, so the mode's own pruning removes them even though this
116
+ // mapper renders no widget of its own.
117
+ const code = await runPlugin({ infiniteScroll: true }, ['previous', 'next'])
118
+
119
+ expect(code).not.toContain('thq-previous-elm')
120
+ expect(code).not.toContain('thq-next-elm')
121
+ })
122
+
123
+ it('does not desynchronise the widget zip when mappers use different modes', async () => {
124
+ // ⛔ The infinite mapper draws no container. Counting one for it would wire
125
+ // THIS mapper's buttons to the other mapper's state.
126
+ const chunk = makeWidgetComponentChunk({ controls: ['previous', 'next'], mappers: 1 })
127
+ const structure: ComponentStructure = {
128
+ uidl: {
129
+ name: 'TestComponent',
130
+ node: makeMultiMapperUidl([{ infiniteScroll: true }, {}]),
131
+ },
132
+ chunks: [chunk],
133
+ dependencies: {},
134
+ options: { dataSources: {}, extractedResources: {} },
135
+ } as never
136
+ await createNextArrayMapperPaginationPlugin()(structure)
137
+ const code = generator(chunk.content as types.Node).code
138
+
139
+ // The single authored widget belongs to the SECOND (paginated) mapper.
140
+ expect(code).toContain('setDs_1_page')
141
+ expect(code).not.toContain('setDs_0_page(page => Math.max(1, page - 1))')
142
+ })
143
+ })
@@ -0,0 +1,113 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import { ComponentStructure } from '@teleporthq/teleport-types'
4
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
5
+ import {
6
+ makeUidlNode,
7
+ makeWidgetComponentChunk,
8
+ type RepeaterOptions,
9
+ } from './_helpers/pagination-widget-fixture'
10
+
11
+ const runPlugin = async (
12
+ repeater: RepeaterOptions,
13
+ controls: string[],
14
+ legacyOnly = false
15
+ ): Promise<string> => {
16
+ const chunk = makeWidgetComponentChunk({ controls, legacyOnly })
17
+ const structure: ComponentStructure = {
18
+ uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
19
+ chunks: [chunk],
20
+ dependencies: {},
21
+ options: { dataSources: {}, extractedResources: {} },
22
+ } as never
23
+ await createNextArrayMapperPaginationPlugin()(structure)
24
+ return generator(chunk.content as types.Node).code
25
+ }
26
+
27
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
28
+
29
+ const NUMBERED_CONTROLS = ['first', 'previous', 'pages', 'next', 'last']
30
+
31
+ describe('pagination plugin — numbered mode', () => {
32
+ it('computes a windowed token list with an ellipsis on each side', async () => {
33
+ const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
34
+ const flat = collapse(code)
35
+
36
+ expect(flat).toContain('const ds_0_pageTokens = useMemo(')
37
+ // No results at all and a single page are answered without a window.
38
+ expect(flat).toContain('if (total <= 0) return []')
39
+ expect(flat).toContain('if (total === 1) return [1]')
40
+ // First and last are always shown, with one neighbour either side.
41
+ expect(flat).toContain('const start = Math.max(2, current - 1)')
42
+ expect(flat).toContain('const end = Math.min(total - 1, current + 1)')
43
+ expect(flat).toContain('if (start > 2) tokens.push("start-ellipsis")')
44
+ expect(flat).toContain('if (end < total - 1) tokens.push("end-ellipsis")')
45
+ expect(flat).toContain('}, [ds_0_maxPages, ds_0_page])')
46
+ })
47
+
48
+ it('repeats the authored template button per page, preserving its class', async () => {
49
+ const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
50
+ const flat = collapse(code)
51
+
52
+ expect(flat).toContain('ds_0_pageTokens.map(token =>')
53
+ expect(flat).toContain('typeof token === "number"')
54
+ // The author's own node is cloned, so their styling survives.
55
+ expect(flat).toContain('className="thq-page-elm"')
56
+ // The current page is MARKED, not disabled — it stays reachable and
57
+ // `[aria-current='page']` is styleable.
58
+ expect(flat).toContain('aria-current={ds_0_page === token ? "page" : undefined}')
59
+ // The `${token}` here is the GENERATED source being asserted on, not an
60
+ // interpolation this file wants performed.
61
+ // tslint:disable-next-line:no-invalid-template-strings
62
+ expect(flat).toContain('key={`page-${token}`}')
63
+ expect(flat).toContain('onClick={() => setDs_0_page(page => page === token ? page : token)}')
64
+ // Whatever label the template carried is replaced by the real number.
65
+ expect(flat).toContain('>{token}<')
66
+ // No authored ellipsis template ⇒ a plain span fills the gap.
67
+ expect(flat).toContain('<span key={token}>…</span>')
68
+ })
69
+
70
+ it('wires First and Last against the live page count', async () => {
71
+ const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
72
+ const flat = collapse(code)
73
+
74
+ expect(flat).toContain('onClick={() => setDs_0_page(page => page === 1 ? page : 1)}')
75
+ expect(flat).toContain('disabled={ds_0_page <= 1}')
76
+ expect(flat).toContain(
77
+ 'onClick={() => setDs_0_page(page => page === ds_0_maxPages ? page : ds_0_maxPages)}'
78
+ )
79
+ // maxPages 0 is "no results", where the last page would be page 0.
80
+ expect(flat).toContain('disabled={ds_0_maxPages === 0 || ds_0_page >= ds_0_maxPages}')
81
+ })
82
+
83
+ it('fetches a live count on a PAGE, which otherwise only has the build-time one', async () => {
84
+ const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
85
+
86
+ // A numbered strip labels every page, so a stale build-time count would
87
+ // print numbers that do not exist.
88
+ expect(code).toContain('-count?')
89
+ expect(code).toContain('setDs_0_maxPages(data.count === 0 ? 0 : Math.ceil(data.count / 20))')
90
+ })
91
+
92
+ it('removes a numbered strip left over from a mode switch', async () => {
93
+ // The builder never deletes controls on a mode switch (that would discard
94
+ // the author's styling), so a Previous/Next list can still carry them.
95
+ const code = await runPlugin({}, NUMBERED_CONTROLS)
96
+
97
+ expect(code).not.toContain('ds_0_pageTokens')
98
+ expect(code).not.toContain('thq-first-elm')
99
+ expect(code).not.toContain('thq-last-elm')
100
+ expect(code).not.toContain('thq-pages-elm')
101
+ // Previous/Next are still wired exactly as before.
102
+ expect(code).toContain('thq-previous-elm')
103
+ expect(code).toContain('disabled={ds_0_page <= 1}')
104
+ })
105
+
106
+ it('still finds Previous/Next by class name in projects predating the marker', async () => {
107
+ const code = await runPlugin({}, ['previous', 'next'], /* legacyOnly */ true)
108
+
109
+ expect(code).toContain('products-list-thq-previous-elm')
110
+ expect(code).toContain('disabled={ds_0_page <= 1}')
111
+ expect(code).toContain('disabled={ds_0_page >= ds_0_maxPages}')
112
+ })
113
+ })