@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,208 @@
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
+ const makeComponentChunk = (): ChunkDefinition => {
12
+ const body = types.blockStatement([
13
+ types.returnStatement(
14
+ types.jsxElement(
15
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
16
+ null,
17
+ [],
18
+ true
19
+ )
20
+ ),
21
+ ])
22
+ const arrow = types.arrowFunctionExpression([types.identifier('props')], body)
23
+ return {
24
+ name: 'jsx-component',
25
+ type: ChunkType.AST,
26
+ fileType: FileType.JS,
27
+ linkAfter: [],
28
+ content: types.variableDeclaration('const', [
29
+ types.variableDeclarator(types.identifier('TestComponent'), arrow),
30
+ ]),
31
+ meta: {},
32
+ }
33
+ }
34
+
35
+ interface ListOptions {
36
+ pageUrlParamKey?: string
37
+ searchEnabled?: boolean
38
+ stateFilter?: string
39
+ infiniteScroll?: boolean
40
+ }
41
+
42
+ // tslint:disable-next-line:no-any
43
+ const makeUidlNode = (options: ListOptions): any => ({
44
+ type: 'data-source-list',
45
+ content: {
46
+ renderPropIdentifier: 'items',
47
+ resourceDefinition: {
48
+ dataSourceId: 'ds1',
49
+ tableName: 'products',
50
+ dataSourceType: 'postgresql',
51
+ },
52
+ resource: {
53
+ params: {
54
+ queryColumns: { content: ['name'] },
55
+ ...(options.stateFilter
56
+ ? {
57
+ filters: {
58
+ content: [
59
+ {
60
+ source: 'category_filter_ids',
61
+ operand: 'array_overlap',
62
+ destination: {
63
+ type: 'dynamic',
64
+ content: { referenceType: 'state', id: options.stateFilter },
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ }
70
+ : {}),
71
+ },
72
+ },
73
+ nodes: {
74
+ success: {
75
+ type: 'cms-list-repeater',
76
+ content: {
77
+ renderPropIdentifier: 'product',
78
+ paginated: true,
79
+ perPage: 20,
80
+ searchEnabled: options.searchEnabled !== false,
81
+ searchDebounce: 300,
82
+ ...(options.pageUrlParamKey ? { pageUrlParamKey: options.pageUrlParamKey } : {}),
83
+ ...(options.infiniteScroll ? { infiniteScroll: true } : {}),
84
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ })
90
+
91
+ const runPlugin = async (options: ListOptions): Promise<string> => {
92
+ const chunk = makeComponentChunk()
93
+ const structure: ComponentStructure = {
94
+ uidl: { name: 'TestComponent', node: makeUidlNode(options) },
95
+ chunks: [chunk],
96
+ dependencies: {},
97
+ options: { dataSources: {}, extractedResources: {} },
98
+ } as never
99
+ await createNextArrayMapperPaginationPlugin()(structure)
100
+ return generator(chunk.content as types.Node).code
101
+ }
102
+
103
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
104
+
105
+ describe('pagination plugin — current page ⇄ URL (pageUrlParamKey)', () => {
106
+ it('adopts the URL page in an effect rather than seeding the state', async () => {
107
+ const code = await runPlugin({ pageUrlParamKey: 'page' })
108
+ const flat = collapse(code)
109
+
110
+ // ⛔ NOT seeded in the useState initializer, unlike the search query.
111
+ // A statically generated page renders page 1 on the server, and React does
112
+ // not repair mismatched ATTRIBUTES when it hydrates — a client that started
113
+ // on page 3 would keep the server's disabled "Previous" for good. Moving
114
+ // the page in an effect makes it a real state transition React will paint.
115
+ expect(code).not.toContain('window.location.search')
116
+ expect(flat).toContain('const [ds_0_state, setDs_0_state] = useState({ page: 1,')
117
+
118
+ expect(code).toContain('const router = useRouter()')
119
+ expect(code).toContain('const ds_0_pageUrlRef = useRef(null)')
120
+ expect(code).toContain('const __urlPage = Math.max(1, parseInt(__raw, 10) || 1)')
121
+ expect(code).toContain('const __statePage = ds_0_state.page')
122
+ expect(code).toContain('const __seenUrlPage = ds_0_pageUrlRef.current')
123
+ })
124
+
125
+ it('decides which side moved, so the two directions cannot race', async () => {
126
+ const code = await runPlugin({ pageUrlParamKey: 'page' })
127
+ const flat = collapse(code)
128
+
129
+ // The URL moved (deep link, <Link>, back/forward) ⇒ adopt it — but ONLY
130
+ // once every replace this component issued has landed. Mid-flight,
131
+ // `router.query` still holds the page the visitor is on their way out of.
132
+ expect(flat).toContain(
133
+ 'if (__tqQuerySyncRef.current.inFlight === 0 && __seenUrlPage !== __urlPage && __urlPage !== __statePage)'
134
+ )
135
+ expect(flat).toContain(
136
+ 'setDs_0_state(state => state.page === __urlPage ? state : { ...state, page: __urlPage })'
137
+ )
138
+ // The page moved (a click, or the filter reset) ⇒ write it. Page 1 is the
139
+ // ABSENCE of the key, so an unpaginated visit keeps a clean URL.
140
+ expect(flat).toContain('if (__urlPage === __statePage) return')
141
+ expect(flat).toContain(
142
+ '__tqWriteQueryParam("page", __statePage <= 1 ? undefined : __statePage)'
143
+ )
144
+ expect(code).toContain('}, [router.query.page, router.isReady, ds_0_state.page])')
145
+ })
146
+
147
+ it('applies to the standalone page state for a paginated-only list', async () => {
148
+ const code = await runPlugin({ pageUrlParamKey: 'page', searchEnabled: false })
149
+ const flat = collapse(code)
150
+
151
+ expect(code).toContain('const __statePage = ds_0_page')
152
+ expect(flat).toContain('setDs_0_page(page => page === __urlPage ? page : __urlPage)')
153
+ })
154
+
155
+ it('runs the URL sync AFTER the filter reset so a deep link wins', async () => {
156
+ const code = await runPlugin({ pageUrlParamKey: 'page', stateFilter: 'selectedCategory' })
157
+
158
+ // Both effects can fire in one flush on a navigation that changes filter and
159
+ // page together. Effects run in emission order, so the URL sync must be last
160
+ // or `?page=3&categoryFilter=X` would land on page 1.
161
+ const resetAt = code.indexOf('ds_0_filterResetRef.current = __sig')
162
+ const syncAt = code.indexOf('const __seenUrlPage = ds_0_pageUrlRef.current')
163
+ expect(resetAt).toBeGreaterThan(-1)
164
+ expect(syncAt).toBeGreaterThan(resetAt)
165
+ })
166
+
167
+ it('writes through the shared writer, so two controls cannot erase each other', async () => {
168
+ const code = await runPlugin({ pageUrlParamKey: 'page', stateFilter: 'selectedCategory' })
169
+ const flat = collapse(code)
170
+
171
+ // ⛔ THE DEFECT THIS GUARDS, seen live: picking a category while on page 3
172
+ // resets the page, so the category write-back and the page write-back fire
173
+ // in the SAME flush. Each used to build its next query from `router.query`,
174
+ // which Next updates asynchronously and does NOT refresh on a component's
175
+ // own setState — so the second `replace` wrote a query that predated the
176
+ // first, and `?categoryFilter=…` vanished even though the list was filtered.
177
+ // The read-back then cleared the category state, the write-back re-asserted
178
+ // it, and the pair swapped values forever: an endless refetch loop.
179
+ //
180
+ // The fix is that neither effect composes a query any more. Both hand one
181
+ // key to `__tqWriteQueryParam`, which merges into the last query the
182
+ // component ASKED for, so the second write builds on the first's payload.
183
+ expect(code).not.toContain('__nextQuery')
184
+ expect(flat).toContain('const base = __tqQuerySyncRef.current.pending || router.query')
185
+ expect(flat).toContain('__tqQuerySyncRef.current.pending = next')
186
+
187
+ // Exactly ONE writer is declared however many controls are bound to the URL.
188
+ expect((code.match(/const __tqWriteQueryParam = /g) || []).length).toBe(1)
189
+ // ...and exactly one `router.replace`, inside it.
190
+ expect((code.match(/router\.replace\(/g) || []).length).toBe(1)
191
+ })
192
+
193
+ it('emits nothing page-URL related without the key (unchanged behaviour)', async () => {
194
+ const code = await runPlugin({})
195
+
196
+ expect(code).toContain('page: 1')
197
+ expect(code).not.toContain('pageUrlRef')
198
+ expect(code).not.toContain('router.query.page')
199
+ expect(code).not.toContain('useRouter')
200
+ })
201
+
202
+ it('ignores the key on an infinite-scroll list, which has no pages to name', async () => {
203
+ const code = await runPlugin({ pageUrlParamKey: 'page', infiniteScroll: true })
204
+
205
+ expect(code).not.toContain('router.query.page')
206
+ expect(code).not.toContain('pageUrlRef')
207
+ })
208
+ })
@@ -0,0 +1,313 @@
1
+ import { parse } from '@babel/parser'
2
+ import {
3
+ PRODUCT_DISCOUNT_SORT_FIELD,
4
+ buildProductDiscountPercentageSql,
5
+ buildProductEffectivePriceSql,
6
+ generateSortFallbackFieldHelper,
7
+ generateSortFieldSqlHelper,
8
+ generateSortTiebreakSql,
9
+ supportsProductPriceSort,
10
+ } from '../src/product-price-sort'
11
+ import { generatePostgreSQLFetcher } from '../src/fetchers/postgresql'
12
+ import { generateTeleportFetcher } from '../src/fetchers/teleport'
13
+ import { generateDataSourceFetcher } from '../src/data-source-fetchers'
14
+
15
+ /**
16
+ * Ordering a products list by what customers actually pay.
17
+ *
18
+ * The sort happens in SQL, before any row reaches JavaScript, and the list is
19
+ * paginated — so re-sorting the fetched page would only reorder 20 of N
20
+ * products. The database therefore has to compute the discounted price itself.
21
+ *
22
+ * These tests cannot execute the SQL (no Postgres here), so they pin the two
23
+ * things that are checkable: the emitted route still parses, and the expression
24
+ * says what the discount rule says.
25
+ */
26
+
27
+ const PG_CONFIG = { connectionString: 'postgres://user:pw@host:5432/db' }
28
+
29
+ describe('generateSortFieldSqlHelper', () => {
30
+ it('rewrites price only for the products table', () => {
31
+ expect(supportsProductPriceSort('teleport_products')).toBe(true)
32
+ expect(supportsProductPriceSort('teleport_orders')).toBe(false)
33
+
34
+ expect(generateSortFieldSqlHelper('teleport_products')).toContain('if (field === "price")')
35
+ // Every other table gets the identity, so a fetcher can embed it
36
+ // unconditionally and no unrelated sort can change.
37
+ const other = generateSortFieldSqlHelper('teleport_orders')
38
+ expect(other).not.toContain('if (field ===')
39
+ expect(other).toContain('return field')
40
+ })
41
+
42
+ it('emits the SQL as a double-quoted literal, carrying no template syntax', () => {
43
+ // The helper is interpolated into a fetcher's template literal. A backtick
44
+ // or a `${` inside it would close or interpolate that template — which is
45
+ // exactly how this first went wrong.
46
+ const helper = generateSortFieldSqlHelper('teleport_products')
47
+ expect(helper).not.toContain('`')
48
+ expect(helper).not.toContain('${')
49
+ })
50
+
51
+ it('is a syntactically valid JS function', () => {
52
+ expect(() =>
53
+ parse(generateSortFieldSqlHelper('teleport_products'), { sourceType: 'script' })
54
+ ).not.toThrow()
55
+ })
56
+ })
57
+
58
+ describe('the effective-price expression', () => {
59
+ const sql = buildProductEffectivePriceSql()
60
+
61
+ it('falls back to the list price when nothing is live', () => {
62
+ expect(sql.startsWith('COALESCE((')).toBe(true)
63
+ expect(sql.trimEnd().endsWith('), "price"::numeric)')).toBe(true)
64
+ })
65
+
66
+ it('applies the same rule as the discount module', () => {
67
+ // Percentage off the list price, capped at 100; fixed subtracted; floored
68
+ // at zero and rounded to the cent — `computeDiscountedPrice`'s contract.
69
+ expect(sql).toContain(`"price"::numeric * (1 - LEAST(entry.amount, 100) / 100)`)
70
+ expect(sql).toContain(`"price"::numeric - entry.amount`)
71
+ expect(sql).toContain('ROUND(GREATEST(0,')
72
+ expect(sql).toContain(', 2)')
73
+ })
74
+
75
+ it('takes the FIRST live entry in array order, like resolveActiveDiscount', () => {
76
+ expect(sql).toContain('WITH ORDINALITY')
77
+ expect(sql).toContain('ORDER BY entry.ord')
78
+ expect(sql).toContain('LIMIT 1')
79
+ })
80
+
81
+ it('honours the half-open window', () => {
82
+ // `[startsAt, endsAt)` — inclusive start, exclusive end, so back-to-back
83
+ // discounts hand over with no overlap.
84
+ expect(sql).toContain('entry.starts_at <=')
85
+ expect(sql).toContain('entry.ends_at >')
86
+ })
87
+
88
+ it('ignores entries the parser would drop', () => {
89
+ expect(sql).toContain('entry.amount > 0')
90
+ expect(sql).toContain(`entry.kind IN ('percentage', 'fixed')`)
91
+ })
92
+
93
+ it('never casts anything that could raise', () => {
94
+ // It runs inside ORDER BY for the whole table: one unparseable row would
95
+ // empty the products page.
96
+ // JSON is cast only when the text already looks like an array…
97
+ expect(sql).toContain(`btrim("discounts") NOT LIKE '[%'`)
98
+ expect(sql).toContain(`'[]'::jsonb`)
99
+ // …the value only when it is a bare decimal…
100
+ expect(sql).toContain(`~ '^[0-9]+(\\.[0-9]+)?$'`)
101
+ // …and the window bounds are compared as TEXT, never cast to a timestamp.
102
+ // `toISOString()` is fixed-width UTC, so it sorts lexicographically in the
103
+ // same order it sorts chronologically.
104
+ expect(sql).not.toContain('::timestamp')
105
+ expect(sql).toContain(`to_char(now() AT TIME ZONE 'UTC'`)
106
+ })
107
+ })
108
+
109
+ describe('the generated Postgres route', () => {
110
+ const productsRoute = generatePostgreSQLFetcher(PG_CONFIG, 'teleport_products')
111
+
112
+ it('parses as valid JavaScript', () => {
113
+ expect(() => parse(productsRoute, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
114
+ })
115
+
116
+ it('orders through the mapper rather than the raw field', () => {
117
+ expect(productsRoute).toContain('sortFieldSql(sort.field)')
118
+ expect(productsRoute).toContain('if (field === "price")')
119
+ })
120
+
121
+ it('keeps a plain-column ORDER BY to fall back to', () => {
122
+ // If a database ever rejects the sub-select, the page must degrade to the
123
+ // previous ordering rather than serve nothing.
124
+ expect(productsRoute).toContain('const usedDiscountAwareSort = orderBySql !== plainOrderBySql')
125
+ expect(productsRoute).toContain('if (!usedDiscountAwareSort) throw sortError')
126
+ expect(productsRoute).toContain('baseSql + plainOrderBySql + sqlTail')
127
+ })
128
+
129
+ it('leaves every other table ordering exactly as it was', () => {
130
+ const ordersRoute = generatePostgreSQLFetcher(PG_CONFIG, 'teleport_orders')
131
+ expect(ordersRoute).toContain('sortFieldSql(sort.field)')
132
+ expect(ordersRoute).not.toContain('if (field ===')
133
+ expect(ordersRoute).not.toContain('jsonb_array_elements')
134
+ })
135
+
136
+ it('still parses for a non-products table', () => {
137
+ expect(() =>
138
+ parse(generatePostgreSQLFetcher(PG_CONFIG, 'teleport_orders'), { sourceType: 'module' })
139
+ ).not.toThrow()
140
+ })
141
+ })
142
+
143
+ describe('the discount ordering', () => {
144
+ const sql = buildProductDiscountPercentageSql()
145
+
146
+ it('sorts on the real `discounts` column, never a virtual field name', () => {
147
+ // The same field reaches sixteen other fetchers and the editor's data API,
148
+ // none of which know about the rewrite. A name that is not a column becomes
149
+ // `ORDER BY discount` and fails the entire query; the real column can only
150
+ // ever be ordered by uselessly.
151
+ expect(PRODUCT_DISCOUNT_SORT_FIELD).toBe('discounts')
152
+ expect(generateSortFieldSqlHelper('teleport_products')).toContain('if (field === "discounts")')
153
+ })
154
+
155
+ it('measures the markdown as a percentage of the list price', () => {
156
+ // Comparable across a mixed catalogue: $20 off a $2000 sofa is a worse deal
157
+ // than $5 off a $10 mug, and an absolute saving would rank them backwards.
158
+ expect(sql).toContain('* 100')
159
+ expect(sql).toContain('/ "price"::numeric')
160
+ expect(sql).toContain(buildProductEffectivePriceSql())
161
+ })
162
+
163
+ it('cannot divide by zero, and treats a priceless row as undiscounted', () => {
164
+ // `NULL > 0` is NULL, which is not true — so a NULL price takes the ELSE.
165
+ expect(sql).toContain('WHEN "price"::numeric > 0')
166
+ expect(sql).toContain('ELSE 0')
167
+ })
168
+
169
+ it('rounds, so ties are stable rather than floating-point noise', () => {
170
+ expect(sql).toContain('ROUND(')
171
+ expect(sql).toContain(', 4)')
172
+ })
173
+ })
174
+
175
+ describe('degrading safely', () => {
176
+ it('falls back to a column that is certain to exist', () => {
177
+ const helper = generateSortFallbackFieldHelper('teleport_products')
178
+ // Never back to `discounts` itself: it is the JSON column, and a store
179
+ // provisioned before discounts shipped does not have it at all — retrying
180
+ // with it would fail exactly like the expression did.
181
+ expect(helper).toContain('if (field === "price" || field === "discounts")')
182
+ expect(helper).toContain('return "price"')
183
+ })
184
+
185
+ it('leaves other tables and other fields alone', () => {
186
+ const helper = generateSortFallbackFieldHelper('teleport_orders')
187
+ expect(helper).not.toContain('if (field ===')
188
+ expect(helper).toContain('return field')
189
+ })
190
+
191
+ it('is valid JS for both tables', () => {
192
+ expect(() =>
193
+ parse(generateSortFallbackFieldHelper('teleport_products'), { sourceType: 'script' })
194
+ ).not.toThrow()
195
+ expect(() =>
196
+ parse(generateSortFallbackFieldHelper('teleport_orders'), { sourceType: 'script' })
197
+ ).not.toThrow()
198
+ })
199
+ })
200
+
201
+ describe('stable pagination', () => {
202
+ it('breaks ties on the products table so pages cannot repeat or skip rows', () => {
203
+ // "Sort by discount" ties every undiscounted product at zero. Equal rows
204
+ // with no defined order can appear on two pages, or on none.
205
+ expect(generateSortTiebreakSql('teleport_products')).toBe(', "id" ASC')
206
+ })
207
+
208
+ it('adds nothing to a table it does not own', () => {
209
+ expect(generateSortTiebreakSql('teleport_orders')).toBe('')
210
+ })
211
+
212
+ it('is applied to both the real and the fallback ORDER BY', () => {
213
+ const route = generatePostgreSQLFetcher(PG_CONFIG, 'teleport_products')
214
+ const orderByLines = route
215
+ .split('\n')
216
+ .filter((line) => line.includes('ORDER BY') && line.includes('join'))
217
+ expect(orderByLines).toHaveLength(2)
218
+ orderByLines.forEach((line) => expect(line).toContain('"id" ASC'))
219
+ })
220
+ })
221
+
222
+ /**
223
+ * The half of this that was missed the first time.
224
+ *
225
+ * A generated storefront's products table is TELEPORT-hosted, so its route is
226
+ * built by `generateTeleportFetcher` — not by `generatePostgreSQLFetcher`,
227
+ * which every test above exercises. The rewrite went into the Postgres fetcher
228
+ * alone, so a real store still ordered by the undiscounted list price while the
229
+ * whole suite passed.
230
+ *
231
+ * These tests go through `generateFetcher`, the function the project generator
232
+ * actually calls, so what is asserted is the route a store really ships.
233
+ */
234
+ describe('the fetcher a generated storefront actually gets', () => {
235
+ const teleportProducts = generateDataSourceFetcher(
236
+ { type: 'teleport', config: { host: 'db', database: 'store' } },
237
+ 'teleport_products'
238
+ )
239
+
240
+ it('is discount-aware for a teleport-hosted products table', () => {
241
+ expect(teleportProducts).toContain('sortFieldSql(sort.field)')
242
+ expect(teleportProducts).toContain('if (field === "price")')
243
+ expect(teleportProducts).toContain('if (field === "discounts")')
244
+ expect(teleportProducts).toContain('jsonb_array_elements')
245
+ })
246
+
247
+ it('keeps the plain-column fallback and the tiebreaker', () => {
248
+ expect(teleportProducts).toContain(
249
+ 'const usedDiscountAwareSort = orderBySql !== plainOrderBySql'
250
+ )
251
+ expect(teleportProducts).toContain('if (!usedDiscountAwareSort) throw sortError')
252
+ expect(teleportProducts).toContain('baseSql + plainOrderBySql + sqlTail')
253
+ expect(teleportProducts).toContain('"id" ASC')
254
+ })
255
+
256
+ it('parses as valid JavaScript', () => {
257
+ expect(() => parse(teleportProducts, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
258
+ })
259
+
260
+ it('leaves a teleport-hosted table that is not products alone', () => {
261
+ const orders = generateDataSourceFetcher(
262
+ { type: 'teleport', config: { host: 'db', database: 'store' } },
263
+ 'teleport_orders'
264
+ )
265
+ expect(orders).not.toContain('if (field ===')
266
+ expect(orders).not.toContain('jsonb_array_elements')
267
+ expect(() => parse(orders, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
268
+ })
269
+
270
+ it('is discount-aware for a self-hosted Postgres products table too', () => {
271
+ const pg = generateDataSourceFetcher(
272
+ { type: 'postgresql', config: PG_CONFIG },
273
+ 'teleport_products'
274
+ )
275
+ expect(pg).toContain('if (field === "price")')
276
+ })
277
+
278
+ /**
279
+ * The guard that makes the miss above impossible to repeat: every SQL data
280
+ * source that can serve a products table must route its sorts through
281
+ * `sortFieldSql`. A new Postgres-compatible fetcher, or a rewrite of an
282
+ * existing one, fails here rather than silently shipping the list price.
283
+ */
284
+ it.each([
285
+ ['teleport', { host: 'db', database: 'store' }],
286
+ ['postgresql', PG_CONFIG],
287
+ ['cockroachdb', PG_CONFIG],
288
+ ])('routes %s sorts through the mapper', (type, config) => {
289
+ const route = generateDataSourceFetcher(
290
+ { type, config } as unknown as Parameters<typeof generateDataSourceFetcher>[0],
291
+ 'teleport_products'
292
+ )
293
+ expect(route).toContain('sortFieldSql(sort.field)')
294
+ expect(route).not.toMatch(/return `\$\{sort\.field\} \$\{order\}`/)
295
+ })
296
+ })
297
+
298
+ describe('the teleport fetcher, directly', () => {
299
+ it('emits the helpers exactly once, so the route has no duplicate declaration', () => {
300
+ const route = generateTeleportFetcher({ host: 'db' }, 'teleport_products')
301
+ expect(route.split('function sortFieldSql(')).toHaveLength(2)
302
+ expect(route.split('function sortFallbackField(')).toHaveLength(2)
303
+ })
304
+
305
+ it('does not add an ORDER BY to the count query', () => {
306
+ // The count route shares this file; a tiebreaker or a sub-select there
307
+ // would be wasted work at best and a syntax error at worst.
308
+ const route = generateTeleportFetcher({ host: 'db' }, 'teleport_products')
309
+ const countBody = route.slice(route.indexOf('async function getCount'))
310
+ expect(countBody).not.toContain('ORDER BY ${')
311
+ expect(countBody).not.toContain('sortFieldSql(')
312
+ })
313
+ })
@@ -96,9 +96,12 @@ describe('pagination plugin — search input URL two-way sync (searchUrlParamKey
96
96
  // useRouter is injected for the effects.
97
97
  expect(code).toContain('const router = useRouter()')
98
98
 
99
- // Write-back (debounced query → URL), keyed on the DEBOUNCED value.
100
- expect(code).toContain('__nextQuery.searchKeyword = String(ds_0_state.debouncedQuery)')
101
- expect(code).toContain('if (__nextQuery.searchKeyword === router.query.searchKeyword) return')
99
+ // Write-back (debounced query → URL), keyed on the DEBOUNCED value and
100
+ // routed through the shared writer so it cannot race the page's own write.
101
+ expect(code).toContain('const __tqQuerySyncRef = useRef(')
102
+ expect(code).toContain(
103
+ '__tqWriteQueryParam("searchKeyword", ds_0_state.debouncedQuery === "" || ds_0_state.debouncedQuery == null ? undefined : ds_0_state.debouncedQuery)'
104
+ )
102
105
  expect(code).toContain('}, [ds_0_state.debouncedQuery, router.isReady])')
103
106
 
104
107
  // Read-back (URL → input), functional setState bail-out (loop-free).
@@ -115,6 +118,7 @@ describe('pagination plugin — search input URL two-way sync (searchUrlParamKey
115
118
  expect(code).not.toContain('searchKeyword')
116
119
  expect(code).not.toContain('window.location.search')
117
120
  expect(code).not.toContain('router.replace')
121
+ expect(code).not.toContain('__tqWriteQueryParam')
118
122
  expect(code).not.toContain('useRouter')
119
123
  })
120
124
  })
@@ -1,4 +1,13 @@
1
1
  import { UIDLElementNode, UIDLNode } from '@teleporthq/teleport-types';
2
+ /**
3
+ * @deprecated Not part of the generation pipeline.
4
+ *
5
+ * Array-mapper pagination is generated by `pagination-plugin.ts` (with
6
+ * `numbered-pagination.ts`, `infinite-scroll.ts` and `pagination-controls.ts`).
7
+ * This module models an older client-side slicing design and is kept only
8
+ * because it is re-exported from the package index; nothing in the pipeline
9
+ * calls it. Add nothing here — the two would drift apart in silence.
10
+ */
2
11
  export interface ArrayMapperPaginationInfo {
3
12
  paginationNodeId: string;
4
13
  dataSourceIdentifier: string;
@@ -1 +1 @@
1
- {"version":3,"file":"array-mapper-pagination.d.ts","sourceRoot":"","sources":["../../src/array-mapper-pagination.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAA;AAGtE,MAAM,WAAW,yBAAyB;IACxC,gBAAgB,EAAE,MAAM,CAAA;IACxB,oBAAoB,EAAE,MAAM,CAAA;IAC5B,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAC9B,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,MAAM,CAAA;IACvB,cAAc,EAAE,MAAM,CAAA;IACtB,cAAc,EAAE,MAAM,CAAA;IACtB,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAChC,0BAA0B,CAAC,EAAE,MAAM,CAAA;IACnC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,eAAe,CAAA;IAC/B,oBAAoB,EAAE,MAAM,CAAA;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,eAAe,GAAG,IAAI,CAAA;IAClC,UAAU,EAAE,eAAe,GAAG,IAAI,CAAA;CACnC;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,GAAG,kBAAkB,EAAE,CA2CxE;AAED,wBAAgB,uBAAuB,CACrC,gBAAgB,EAAE,MAAM,EACxB,oBAAoB,EAAE,MAAM,EAC5B,OAAO,GAAE,MAAW,EACpB,YAAY,CAAC,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,EACjE,YAAY,CAAC,EAAE,MAAM,EAAE,GACtB,yBAAyB,CA+B3B"}
1
+ {"version":3,"file":"array-mapper-pagination.d.ts","sourceRoot":"","sources":["../../src/array-mapper-pagination.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAA;AAGtE;;;;;;;;GAQG;AAEH,MAAM,WAAW,yBAAyB;IACxC,gBAAgB,EAAE,MAAM,CAAA;IACxB,oBAAoB,EAAE,MAAM,CAAA;IAC5B,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAC9B,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,MAAM,CAAA;IACvB,cAAc,EAAE,MAAM,CAAA;IACtB,cAAc,EAAE,MAAM,CAAA;IACtB,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAChC,0BAA0B,CAAC,EAAE,MAAM,CAAA;IACnC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,eAAe,CAAA;IAC/B,oBAAoB,EAAE,MAAM,CAAA;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,eAAe,GAAG,IAAI,CAAA;IAClC,UAAU,EAAE,eAAe,GAAG,IAAI,CAAA;CACnC;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,GAAG,kBAAkB,EAAE,CA2CxE;AAED,wBAAgB,uBAAuB,CACrC,gBAAgB,EAAE,MAAM,EACxB,oBAAoB,EAAE,MAAM,EAC5B,OAAO,GAAE,MAAW,EACpB,YAAY,CAAC,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,EACjE,YAAY,CAAC,EAAE,MAAM,EAAE,GACtB,yBAAyB,CA+B3B"}
@@ -1 +1 @@
1
- {"version":3,"file":"array-mapper-pagination.js","sourceRoot":"","sources":["../../src/array-mapper-pagination.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"array-mapper-pagination.js","sourceRoot":"","sources":["../../src/array-mapper-pagination.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ import * as types from '@babel/types';
2
+ /**
3
+ * The client-side "how many rows match?" fetch that drives `ds_N_maxPages`.
4
+ *
5
+ * One builder for every caller, because the two hand-written copies had already
6
+ * drifted: the search-enabled list sent the query, the columns and the filters,
7
+ * while the paginated-only list fetched a bare `/api/<file>-count` with NO
8
+ * params at all — so a filtered list counted the WHOLE table and "Next" stayed
9
+ * enabled long past the end of the filtered results.
10
+ *
11
+ * The endpoint deliberately receives no `sorts` and no `limit`: ordering cannot
12
+ * change how many rows match, and a limit would count the page rather than the
13
+ * result set.
14
+ */
15
+ interface CountEffectOptions {
16
+ /** `<fileName>-count` is the generated API route. */
17
+ fileName: string;
18
+ /** Divisor for the page count; the mapper's resolved `perPage`. */
19
+ perPage: number;
20
+ /** Name of the `setDs_N_maxPages` setter. */
21
+ setMaxPagesVar: string;
22
+ /**
23
+ * Params sent to the count endpoint: the search query (when the list has
24
+ * one), `queryColumns`, and the filter array. Built by the caller because
25
+ * only it knows how this usage's filter destinations resolve to expressions.
26
+ */
27
+ urlParams: types.ObjectProperty[];
28
+ /** Dependencies that should re-run the count. */
29
+ deps: types.Expression[];
30
+ }
31
+ export declare const buildCountFetchStatement: (options: Pick<CountEffectOptions, 'fileName' | 'perPage' | 'setMaxPagesVar' | 'urlParams'>) => types.Statement;
32
+ /**
33
+ * The whole `useEffect(...)` wrapper.
34
+ *
35
+ * ⛔ No skip-on-mount guard, on purpose: a page seeds `maxPages` from the
36
+ * build-time `getStaticProps` count, and that snapshot goes stale the moment
37
+ * rows are added after the build — leaving "Next" disabled on a list that has
38
+ * grown. Re-counting on mount is what corrects it.
39
+ */
40
+ export declare const buildCountFetchEffect: (options: CountEffectOptions) => types.ExpressionStatement;
41
+ export {};
42
+ //# sourceMappingURL=count-effect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"count-effect.d.ts","sourceRoot":"","sources":["../../src/count-effect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAErC;;;;;;;;;;;;GAYG;AAEH,UAAU,kBAAkB;IAC1B,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAA;IACf,6CAA6C;IAC7C,cAAc,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,SAAS,EAAE,KAAK,CAAC,cAAc,EAAE,CAAA;IACjC,iDAAiD;IACjD,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAA;CACzB;AA6BD,eAAO,MAAM,wBAAwB,YAC1B,KAAK,kBAAkB,EAAE,UAAU,GAAG,SAAS,GAAG,gBAAgB,GAAG,WAAW,CAAC,KACzF,MAAM,SA0DN,CAAA;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,YAAa,kBAAkB,KAAG,MAAM,mBAMvE,CAAA"}