@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.
Files changed (147) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
  2. package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
  3. package/__tests__/ecommerce-product-discounts.test.ts +196 -0
  4. package/__tests__/ecommerce-product-model.test.ts +179 -0
  5. package/__tests__/infinite-scroll.test.ts +143 -0
  6. package/__tests__/numbered-pagination.test.ts +113 -0
  7. package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
  8. package/__tests__/page-url-sync.test.ts +208 -0
  9. package/__tests__/product-price-sort.ts +313 -0
  10. package/__tests__/search-url-sync.test.ts +7 -3
  11. package/dist/cjs/array-mapper-pagination.d.ts +9 -0
  12. package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
  13. package/dist/cjs/array-mapper-pagination.js.map +1 -1
  14. package/dist/cjs/count-effect.d.ts +42 -0
  15. package/dist/cjs/count-effect.d.ts.map +1 -0
  16. package/dist/cjs/count-effect.js +81 -0
  17. package/dist/cjs/count-effect.js.map +1 -0
  18. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/postgresql.js +2 -1
  20. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  21. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/teleport.js +3 -2
  23. package/dist/cjs/fetchers/teleport.js.map +1 -1
  24. package/dist/cjs/infinite-scroll.d.ts +65 -0
  25. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  26. package/dist/cjs/infinite-scroll.js +190 -0
  27. package/dist/cjs/infinite-scroll.js.map +1 -0
  28. package/dist/cjs/numbered-pagination.d.ts +42 -0
  29. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  30. package/dist/cjs/numbered-pagination.js +167 -0
  31. package/dist/cjs/numbered-pagination.js.map +1 -0
  32. package/dist/cjs/page-reset.d.ts +42 -0
  33. package/dist/cjs/page-reset.d.ts.map +1 -0
  34. package/dist/cjs/page-reset.js +116 -0
  35. package/dist/cjs/page-reset.js.map +1 -0
  36. package/dist/cjs/page-url-sync.d.ts +42 -0
  37. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  38. package/dist/cjs/page-url-sync.js +180 -0
  39. package/dist/cjs/page-url-sync.js.map +1 -0
  40. package/dist/cjs/pagination-controls.d.ts +55 -0
  41. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  42. package/dist/cjs/pagination-controls.js +159 -0
  43. package/dist/cjs/pagination-controls.js.map +1 -0
  44. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  45. package/dist/cjs/pagination-plugin.js +506 -160
  46. package/dist/cjs/pagination-plugin.js.map +1 -1
  47. package/dist/cjs/product-price-sort.d.ts +116 -0
  48. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  49. package/dist/cjs/product-price-sort.js +169 -0
  50. package/dist/cjs/product-price-sort.js.map +1 -0
  51. package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
  52. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  53. package/dist/cjs/transformations/ecommerce-product.js +2 -1
  54. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  55. package/dist/cjs/transformations/index.d.ts +2 -1
  56. package/dist/cjs/transformations/index.d.ts.map +1 -1
  57. package/dist/cjs/transformations/index.js +17 -1
  58. package/dist/cjs/transformations/index.js.map +1 -1
  59. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  60. package/dist/cjs/transformations/shared-utils.js +1 -1
  61. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  62. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  63. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  64. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  65. package/dist/esm/array-mapper-pagination.js.map +1 -1
  66. package/dist/esm/count-effect.d.ts +42 -0
  67. package/dist/esm/count-effect.d.ts.map +1 -0
  68. package/dist/esm/count-effect.js +53 -0
  69. package/dist/esm/count-effect.js.map +1 -0
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +2 -1
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  74. package/dist/esm/fetchers/teleport.js +3 -2
  75. package/dist/esm/fetchers/teleport.js.map +1 -1
  76. package/dist/esm/infinite-scroll.d.ts +65 -0
  77. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  78. package/dist/esm/infinite-scroll.js +159 -0
  79. package/dist/esm/infinite-scroll.js.map +1 -0
  80. package/dist/esm/numbered-pagination.d.ts +42 -0
  81. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  82. package/dist/esm/numbered-pagination.js +138 -0
  83. package/dist/esm/numbered-pagination.js.map +1 -0
  84. package/dist/esm/page-reset.d.ts +42 -0
  85. package/dist/esm/page-reset.d.ts.map +1 -0
  86. package/dist/esm/page-reset.js +87 -0
  87. package/dist/esm/page-reset.js.map +1 -0
  88. package/dist/esm/page-url-sync.d.ts +42 -0
  89. package/dist/esm/page-url-sync.d.ts.map +1 -0
  90. package/dist/esm/page-url-sync.js +152 -0
  91. package/dist/esm/page-url-sync.js.map +1 -0
  92. package/dist/esm/pagination-controls.d.ts +55 -0
  93. package/dist/esm/pagination-controls.d.ts.map +1 -0
  94. package/dist/esm/pagination-controls.js +126 -0
  95. package/dist/esm/pagination-controls.js.map +1 -0
  96. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  97. package/dist/esm/pagination-plugin.js +507 -161
  98. package/dist/esm/pagination-plugin.js.map +1 -1
  99. package/dist/esm/product-price-sort.d.ts +116 -0
  100. package/dist/esm/product-price-sort.d.ts.map +1 -0
  101. package/dist/esm/product-price-sort.js +160 -0
  102. package/dist/esm/product-price-sort.js.map +1 -0
  103. package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
  104. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  105. package/dist/esm/transformations/ecommerce-product.js +3 -2
  106. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  107. package/dist/esm/transformations/index.d.ts +2 -1
  108. package/dist/esm/transformations/index.d.ts.map +1 -1
  109. package/dist/esm/transformations/index.js +17 -1
  110. package/dist/esm/transformations/index.js.map +1 -1
  111. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  112. package/dist/esm/transformations/shared-utils.js +1 -1
  113. package/dist/esm/transformations/shared-utils.js.map +1 -1
  114. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +5 -5
  116. package/src/array-mapper-pagination.ts +10 -0
  117. package/src/count-effect.ts +137 -0
  118. package/src/fetchers/postgresql.ts +52 -13
  119. package/src/fetchers/teleport.ts +53 -13
  120. package/src/infinite-scroll.ts +401 -0
  121. package/src/numbered-pagination.ts +292 -0
  122. package/src/page-reset.ts +162 -0
  123. package/src/page-url-sync.ts +241 -0
  124. package/src/pagination-controls.ts +176 -0
  125. package/src/pagination-plugin.ts +755 -346
  126. package/src/product-price-sort.ts +204 -0
  127. package/src/transformations/ecommerce-product.ts +141 -10
  128. package/src/transformations/index.ts +19 -1
  129. package/src/transformations/shared-utils.ts +14 -0
  130. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  131. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  132. package/dist/cjs/array-mapper-registry.js +0 -296
  133. package/dist/cjs/array-mapper-registry.js.map +0 -1
  134. package/dist/cjs/pagination-with-count.d.ts +0 -6
  135. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  136. package/dist/cjs/pagination-with-count.js +0 -63
  137. package/dist/cjs/pagination-with-count.js.map +0 -1
  138. package/dist/esm/array-mapper-registry.d.ts +0 -85
  139. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  140. package/dist/esm/array-mapper-registry.js +0 -292
  141. package/dist/esm/array-mapper-registry.js.map +0 -1
  142. package/dist/esm/pagination-with-count.d.ts +0 -6
  143. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  144. package/dist/esm/pagination-with-count.js +0 -34
  145. package/dist/esm/pagination-with-count.js.map +0 -1
  146. package/src/array-mapper-registry.ts +0 -416
  147. package/src/pagination-with-count.ts +0 -89
@@ -0,0 +1,179 @@
1
+ import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+
4
+ // A product's optional 3D model. The transform must hand the storefront
5
+ // resolved URLs, STRING gates ('true' / 'false' — the builders compare with
6
+ // `=`/`!=`, so an absent field reads as the IMAGE branch), and the gallery as
7
+ // media entries in display order. MUST stay in sync with the renderer mirror
8
+ // (packages/renderer/src/utils/ecommerce-products.ts) and the GUI's
9
+ // `PRODUCT_PROP_DEFAULT` — see the GUI-side specs.
10
+
11
+ type Product = Record<string, unknown> & {
12
+ galleryMedia: Array<{ kind: string; src: string; poster: string; thumbnail: string; alt: string }>
13
+ galleryThumbnails: Array<{ kind: string; src: string }>
14
+ }
15
+
16
+ const evalBuildProduct = (): ((record: unknown, options?: unknown) => Product) => {
17
+ const code =
18
+ generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
19
+ const fn = new Function(code + '\nreturn buildEcommerceProduct;')
20
+ return fn() as (record: unknown, options?: unknown) => Product
21
+ }
22
+
23
+ const ASSET_MAP = {
24
+ TQ_model: { remoteSrc: 'https://storage.example.com/p/model.glb' },
25
+ TQ_poster: { remoteSrc: 'https://cdn.example.com/poster.png' },
26
+ TQ_img1: { remoteSrc: 'https://cdn.example.com/img1.jpg' },
27
+ TQ_img2: { remoteSrc: 'https://cdn.example.com/img2.jpg' },
28
+ }
29
+
30
+ const baseRecord = {
31
+ id: 'p1',
32
+ name: 'Chair',
33
+ slug: 'chair',
34
+ price: '10',
35
+ currency: 'USD',
36
+ image_url: 'TQ_img1',
37
+ gallery_images: JSON.stringify(['TQ_img2']),
38
+ }
39
+
40
+ describe('ecommerce product transform — 3D model', () => {
41
+ const buildEcommerceProduct = evalBuildProduct()
42
+ const build = (record: Record<string, unknown>) =>
43
+ buildEcommerceProduct(record, { assetMap: ASSET_MAP })
44
+
45
+ it('emits the image branch for a product without a model', () => {
46
+ const product = build(baseRecord)
47
+ expect(product.modelUrl).toBeNull()
48
+ expect(product.modelPoster).toBeNull()
49
+ expect(product.hasModel).toBe('false')
50
+ expect(product.showModel).toBe('false')
51
+ expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image'])
52
+ expect(product.galleryThumbnails.map((m) => m.src)).toEqual([ASSET_MAP.TQ_img2.remoteSrc])
53
+ })
54
+
55
+ it('resolves the model and poster asset ids to URLs and shows the model by default', () => {
56
+ const product = build({ ...baseRecord, model_url: 'TQ_model', model_poster_url: 'TQ_poster' })
57
+ expect(product.modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
58
+ expect(product.modelPoster).toBe(ASSET_MAP.TQ_poster.remoteSrc)
59
+ expect(product.hasModel).toBe('true')
60
+ // NULL / absent flag = ON
61
+ expect(product.showModel).toBe('true')
62
+ // hero first, then the images
63
+ expect(product.galleryMedia.map((m) => m.kind)).toEqual(['model', 'image', 'image'])
64
+ expect(product.galleryMedia[0]).toEqual({
65
+ kind: 'model',
66
+ src: ASSET_MAP.TQ_model.remoteSrc,
67
+ poster: ASSET_MAP.TQ_poster.remoteSrc,
68
+ thumbnail: ASSET_MAP.TQ_poster.remoteSrc,
69
+ alt: 'Chair',
70
+ })
71
+ // the main image moves into the strip when the model is the hero
72
+ expect(product.galleryThumbnails.map((m) => m.src)).toEqual([
73
+ ASSET_MAP.TQ_img1.remoteSrc,
74
+ ASSET_MAP.TQ_img2.remoteSrc,
75
+ ])
76
+ })
77
+
78
+ it('keeps the image hero when the merchant switched the model off, with the model last', () => {
79
+ const product = build({
80
+ ...baseRecord,
81
+ model_url: 'TQ_model',
82
+ model_poster_url: 'TQ_poster',
83
+ show_model_by_default: false,
84
+ })
85
+ expect(product.hasModel).toBe('true')
86
+ expect(product.showModel).toBe('false')
87
+ expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image', 'model'])
88
+ expect(product.galleryThumbnails.map((m) => m.kind)).toEqual(['image', 'model'])
89
+ expect(product.galleryThumbnails[1].src).toBe(ASSET_MAP.TQ_model.remoteSrc)
90
+ })
91
+
92
+ it.each([
93
+ [true, 'true'],
94
+ [false, 'false'],
95
+ [1, 'true'],
96
+ [0, 'false'],
97
+ ['true', 'true'],
98
+ ['false', 'false'],
99
+ ['t', 'true'],
100
+ ['f', 'false'],
101
+ ['1', 'true'],
102
+ ['0', 'false'],
103
+ [null, 'true'],
104
+ [undefined, 'true'],
105
+ ['', 'true'],
106
+ ])('normalizes show_model_by_default = %p to showModel %s', (raw, expected) => {
107
+ const product = build({ ...baseRecord, model_url: 'TQ_model', show_model_by_default: raw })
108
+ expect(product.showModel).toBe(expected)
109
+ })
110
+
111
+ it('always shows the model when it is the only media, whatever the flag says', () => {
112
+ const product = build({
113
+ id: 'p2',
114
+ name: 'Lamp',
115
+ slug: 'lamp',
116
+ price: '5',
117
+ currency: 'USD',
118
+ model_url: 'TQ_model',
119
+ show_model_by_default: false,
120
+ })
121
+ expect(product.showModel).toBe('true')
122
+ expect(product.galleryMedia).toHaveLength(1)
123
+ expect(product.galleryThumbnails).toEqual([])
124
+ // nothing to poster with — the viewer paints its transparent poster
125
+ expect(product.modelPoster).toBeNull()
126
+ })
127
+
128
+ it('falls back to the main image as poster and thumbnail when the model has none', () => {
129
+ const product = build({ ...baseRecord, model_url: 'TQ_model', show_model_by_default: false })
130
+ expect(product.modelPoster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
131
+ const modelEntry = product.galleryMedia.find((m) => m.kind === 'model')
132
+ expect(modelEntry?.poster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
133
+ expect(modelEntry?.thumbnail).toBe(ASSET_MAP.TQ_img1.remoteSrc)
134
+ })
135
+
136
+ it('treats an asset id the asset map cannot resolve as no model at all', () => {
137
+ // resolveAssetUrl returns unknown ids verbatim; a bare 'TQ_…' must never
138
+ // reach <model-viewer src>.
139
+ const product = build({ ...baseRecord, model_url: 'TQ_deleted', model_poster_url: 'TQ_gone' })
140
+ expect(product.modelUrl).toBeNull()
141
+ expect(product.hasModel).toBe('false')
142
+ expect(product.showModel).toBe('false')
143
+ expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image'])
144
+ })
145
+
146
+ it('drops a stale poster id but keeps the model', () => {
147
+ const product = build({ ...baseRecord, model_url: 'TQ_model', model_poster_url: 'TQ_gone' })
148
+ expect(product.modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
149
+ expect(product.modelPoster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
150
+ })
151
+
152
+ it('accepts raw URLs in the model columns', () => {
153
+ const product = build({
154
+ ...baseRecord,
155
+ model_url: 'https://cdn.example.com/raw.glb',
156
+ model_poster_url: '/local/poster.png',
157
+ })
158
+ expect(product.modelUrl).toBe('https://cdn.example.com/raw.glb')
159
+ expect(product.modelPoster).toBe('/local/poster.png')
160
+ })
161
+
162
+ it('carries the model fields into related products', () => {
163
+ const related = { ...baseRecord, id: 'p9', slug: 'chair-9', model_url: 'TQ_model' }
164
+ const product = buildEcommerceProduct(
165
+ { ...baseRecord, related_product_ids: JSON.stringify(['p9']) },
166
+ { assetMap: ASSET_MAP, relatedProductsById: { p9: related } }
167
+ )
168
+ const relatedProducts = product.relatedProducts as Product[]
169
+ expect(relatedProducts).toHaveLength(1)
170
+ expect(relatedProducts[0].showModel).toBe('true')
171
+ expect(relatedProducts[0].modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
172
+ })
173
+
174
+ it('uses the image alt for pictures and the product name for the model', () => {
175
+ const product = build({ ...baseRecord, image_alt: 'A chair', model_url: 'TQ_model' })
176
+ expect(product.galleryMedia[0].alt).toBe('Chair')
177
+ expect(product.galleryMedia[1].alt).toBe('A chair')
178
+ })
179
+ })
@@ -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
+ })
@@ -0,0 +1,182 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ } from '@teleporthq/teleport-types'
9
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
10
+
11
+ // Same minimal `const TestComponent = (props) => { return <div/> }` shell the
12
+ // search-url-sync suite uses: the reset effect is derived from the UIDL
13
+ // registry, not from the JSX, so an empty body is enough to observe it.
14
+ const makeComponentChunk = (): ChunkDefinition => {
15
+ const body = types.blockStatement([
16
+ types.returnStatement(
17
+ types.jsxElement(
18
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
19
+ null,
20
+ [],
21
+ true
22
+ )
23
+ ),
24
+ ])
25
+ const arrow = types.arrowFunctionExpression([types.identifier('props')], body)
26
+ const declaration = types.variableDeclaration('const', [
27
+ types.variableDeclarator(types.identifier('TestComponent'), arrow),
28
+ ])
29
+ return {
30
+ name: 'jsx-component',
31
+ type: ChunkType.AST,
32
+ fileType: FileType.JS,
33
+ linkAfter: [],
34
+ content: declaration,
35
+ meta: {},
36
+ }
37
+ }
38
+
39
+ interface ListOptions {
40
+ searchEnabled?: boolean
41
+ // A filter destination bound to component state (the category dropdown).
42
+ stateFilter?: string
43
+ // A filter destination read straight from the URL (`?categoryFilter=`).
44
+ urlFilter?: string
45
+ // A dynamic sort expression reading component state (the sort dropdown).
46
+ sortExpression?: string
47
+ }
48
+
49
+ // tslint:disable-next-line:no-any
50
+ const makeUidlNode = (options: ListOptions): any => {
51
+ // tslint:disable-next-line:no-any
52
+ const filters: any[] = []
53
+ if (options.stateFilter) {
54
+ filters.push({
55
+ source: 'category_filter_ids',
56
+ operand: 'array_overlap',
57
+ destination: {
58
+ type: 'dynamic',
59
+ content: { referenceType: 'state', id: options.stateFilter },
60
+ },
61
+ })
62
+ }
63
+ if (options.urlFilter) {
64
+ filters.push({
65
+ source: 'category_filter_ids',
66
+ operand: 'array_overlap',
67
+ destination: {
68
+ type: 'dynamic',
69
+ content: { referenceType: 'urlSearchParams', id: options.urlFilter },
70
+ },
71
+ })
72
+ }
73
+
74
+ return {
75
+ type: 'data-source-list',
76
+ content: {
77
+ renderPropIdentifier: 'items',
78
+ resourceDefinition: {
79
+ dataSourceId: 'ds1',
80
+ tableName: 'products',
81
+ dataSourceType: 'postgresql',
82
+ },
83
+ resource: {
84
+ params: {
85
+ queryColumns: { content: ['name'] },
86
+ ...(filters.length > 0 ? { filters: { content: filters } } : {}),
87
+ },
88
+ },
89
+ nodes: {
90
+ success: {
91
+ type: 'cms-list-repeater',
92
+ content: {
93
+ renderPropIdentifier: 'product',
94
+ paginated: true,
95
+ perPage: 20,
96
+ searchEnabled: options.searchEnabled !== false,
97
+ searchDebounce: 300,
98
+ ...(options.sortExpression
99
+ ? {
100
+ sort: { type: 'expr', content: options.sortExpression },
101
+ sortDirection: { type: 'static', content: 'asc' },
102
+ }
103
+ : {}),
104
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ }
110
+ }
111
+
112
+ // Babel prints object literals across several lines; assertions about a whole
113
+ // statement read better against a whitespace-collapsed copy.
114
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
115
+
116
+ const runPlugin = async (options: ListOptions): Promise<string> => {
117
+ const chunk = makeComponentChunk()
118
+ const structure: ComponentStructure = {
119
+ uidl: { name: 'TestComponent', node: makeUidlNode(options) },
120
+ chunks: [chunk],
121
+ dependencies: {},
122
+ options: { dataSources: {}, extractedResources: {} },
123
+ } as never
124
+ await createNextArrayMapperPaginationPlugin()(structure)
125
+ return generator(chunk.content as types.Node).code
126
+ }
127
+
128
+ describe('pagination plugin — page resets to 1 when a filter or sort changes', () => {
129
+ it('resets the combined state page for a state-bound filter', async () => {
130
+ const code = await runPlugin({ stateFilter: 'selectedCategory' })
131
+
132
+ expect(code).toContain('const ds_0_filterResetRef = useRef(null)')
133
+ expect(code).toContain('const __sig = JSON.stringify([selectedCategory])')
134
+ expect(code).toContain('if (ds_0_filterResetRef.current === __sig) return')
135
+ // First OBSERVED value is recorded, never acted on.
136
+ expect(code).toContain('const __first = ds_0_filterResetRef.current === null')
137
+ expect(code).toContain('if (__first) return')
138
+ // Functional update that bails when already on page 1, so a visitor who
139
+ // never paginated does not get an extra render (and refetch).
140
+ expect(collapse(code)).toContain(
141
+ 'setDs_0_state(state => state.page === 1 ? state : { ...state, page: 1 })'
142
+ )
143
+ expect(code).toContain('}, [selectedCategory])')
144
+ })
145
+
146
+ it('tracks the sort state alongside the filter', async () => {
147
+ const code = await runPlugin({
148
+ stateFilter: 'selectedCategory',
149
+ sortExpression: 'sortBy.split("-")[0]',
150
+ })
151
+
152
+ expect(code).toContain('const __sig = JSON.stringify([selectedCategory, sortBy])')
153
+ expect(code).toContain('}, [selectedCategory, sortBy])')
154
+ })
155
+
156
+ it('guards on router.isReady and null-coalesces URL params so deep links survive', async () => {
157
+ const code = await runPlugin({ urlFilter: 'categoryFilter' })
158
+
159
+ // ⛔ The reason this effect hashes a signature instead of skipping the first
160
+ // run: router.query is empty until the router hydrates, and treating that
161
+ // hydration as a change would reset a `?page=3&categoryFilter=X` deep link.
162
+ expect(code).toContain('if (!router.isReady) return')
163
+ expect(code).toContain('const __sig = JSON.stringify([router.query.categoryFilter ?? null])')
164
+ expect(code).toContain('}, [router.query.categoryFilter, router.isReady])')
165
+ })
166
+
167
+ it('uses the standalone page setter for a paginated-only list', async () => {
168
+ const code = await runPlugin({ searchEnabled: false, stateFilter: 'selectedCategory' })
169
+
170
+ expect(code).toContain('setDs_0_page(page => page === 1 ? page : 1)')
171
+ expect(code).not.toContain('setDs_0_state(')
172
+ })
173
+
174
+ it('emits nothing for a list that has no filter and no dynamic sort', async () => {
175
+ const code = await runPlugin({})
176
+
177
+ // Regression guard: an unfiltered mapper must generate exactly what it did
178
+ // before this feature existed.
179
+ expect(code).not.toContain('filterResetRef')
180
+ expect(code).not.toContain('__sig')
181
+ })
182
+ })