@teleporthq/teleport-plugin-next-data-source 0.43.74 → 0.44.0

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 (231) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +95 -5
  2. package/__tests__/blog-adjacent-arranged.test.ts +176 -0
  3. package/__tests__/blog-post-contents.test.ts +212 -0
  4. package/__tests__/blog-post-page-enrichment.test.ts +28 -5
  5. package/__tests__/blog-post-picture-stand-in.test.ts +48 -0
  6. package/__tests__/blog-post-reading-time.test.ts +55 -0
  7. package/__tests__/booking-item-transform.test.ts +120 -0
  8. package/__tests__/browser-row-policy.test.ts +13 -3
  9. package/__tests__/content-preset-wiring.test.ts +108 -0
  10. package/__tests__/fetchers.test.ts +1 -1
  11. package/__tests__/fixed-list-sort.test.ts +183 -0
  12. package/__tests__/infinite-scroll.test.ts +154 -5
  13. package/__tests__/list-sort-order.test.ts +61 -0
  14. package/__tests__/null-filter-where.test.ts +132 -0
  15. package/__tests__/pagination-filter-groups.test.ts +186 -0
  16. package/__tests__/pg-date-parser.test.ts +73 -0
  17. package/__tests__/plain-list-sort.test.ts +215 -0
  18. package/__tests__/product-price-sort.ts +58 -25
  19. package/__tests__/range-control.test.ts +159 -0
  20. package/__tests__/rest-api-key-header.test.ts +18 -0
  21. package/__tests__/search-input-marker.test.ts +86 -0
  22. package/__tests__/search-url-sync.test.ts +61 -16
  23. package/__tests__/shared-resource-prefetch.test.ts +65 -0
  24. package/__tests__/table-read-route-protected-tables.test.ts +33 -3
  25. package/dist/cjs/count-effect.d.ts +7 -1
  26. package/dist/cjs/count-effect.d.ts.map +1 -1
  27. package/dist/cjs/count-effect.js +15 -5
  28. package/dist/cjs/count-effect.js.map +1 -1
  29. package/dist/cjs/fetchers/pg-client-import.d.ts +12 -0
  30. package/dist/cjs/fetchers/pg-client-import.d.ts.map +1 -0
  31. package/dist/cjs/fetchers/pg-client-import.js +15 -0
  32. package/dist/cjs/fetchers/pg-client-import.js.map +1 -0
  33. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/postgresql.js +4 -1
  35. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  36. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/raw-query.js +2 -1
  38. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  39. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/rest-api.js +3 -1
  41. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  42. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/teleport.js +5 -2
  44. package/dist/cjs/fetchers/teleport.js.map +1 -1
  45. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
  46. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  47. package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
  48. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  49. package/dist/cjs/filter-utils.d.ts +19 -7
  50. package/dist/cjs/filter-utils.d.ts.map +1 -1
  51. package/dist/cjs/filter-utils.js +49 -13
  52. package/dist/cjs/filter-utils.js.map +1 -1
  53. package/dist/cjs/infinite-scroll.d.ts +25 -2
  54. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  55. package/dist/cjs/infinite-scroll.js +144 -33
  56. package/dist/cjs/infinite-scroll.js.map +1 -1
  57. package/dist/cjs/list-sort-order.d.ts +14 -0
  58. package/dist/cjs/list-sort-order.d.ts.map +1 -0
  59. package/dist/cjs/list-sort-order.js +36 -0
  60. package/dist/cjs/list-sort-order.js.map +1 -0
  61. package/dist/cjs/pagination-controls.d.ts +22 -3
  62. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  63. package/dist/cjs/pagination-controls.js +29 -3
  64. package/dist/cjs/pagination-controls.js.map +1 -1
  65. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  66. package/dist/cjs/pagination-plugin.js +267 -212
  67. package/dist/cjs/pagination-plugin.js.map +1 -1
  68. package/dist/cjs/product-price-sort.d.ts +0 -9
  69. package/dist/cjs/product-price-sort.d.ts.map +1 -1
  70. package/dist/cjs/product-price-sort.js +1 -13
  71. package/dist/cjs/product-price-sort.js.map +1 -1
  72. package/dist/cjs/range-control.d.ts +45 -0
  73. package/dist/cjs/range-control.d.ts.map +1 -0
  74. package/dist/cjs/range-control.js +165 -0
  75. package/dist/cjs/range-control.js.map +1 -0
  76. package/dist/cjs/row-order.d.ts +20 -0
  77. package/dist/cjs/row-order.d.ts.map +1 -0
  78. package/dist/cjs/row-order.js +46 -0
  79. package/dist/cjs/row-order.js.map +1 -0
  80. package/dist/cjs/search-input.d.ts +42 -0
  81. package/dist/cjs/search-input.d.ts.map +1 -0
  82. package/dist/cjs/search-input.js +158 -0
  83. package/dist/cjs/search-input.js.map +1 -0
  84. package/dist/cjs/sort-utils.d.ts +10 -0
  85. package/dist/cjs/sort-utils.d.ts.map +1 -1
  86. package/dist/cjs/sort-utils.js +22 -1
  87. package/dist/cjs/sort-utils.js.map +1 -1
  88. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
  89. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
  90. package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
  91. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
  92. package/dist/cjs/transformations/blog-comments.d.ts +1 -1
  93. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
  94. package/dist/cjs/transformations/blog-comments.js +1 -1
  95. package/dist/cjs/transformations/blog-comments.js.map +1 -1
  96. package/dist/cjs/transformations/blog-post.d.ts +14 -0
  97. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  98. package/dist/cjs/transformations/blog-post.js +4 -1
  99. package/dist/cjs/transformations/blog-post.js.map +1 -1
  100. package/dist/cjs/transformations/booking-item.d.ts +24 -0
  101. package/dist/cjs/transformations/booking-item.d.ts.map +1 -0
  102. package/dist/cjs/transformations/booking-item.js +27 -0
  103. package/dist/cjs/transformations/booking-item.js.map +1 -0
  104. package/dist/cjs/transformations/heading-anchors.d.ts +32 -8
  105. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
  106. package/dist/cjs/transformations/heading-anchors.js +34 -10
  107. package/dist/cjs/transformations/heading-anchors.js.map +1 -1
  108. package/dist/cjs/transformations/index.d.ts +27 -13
  109. package/dist/cjs/transformations/index.d.ts.map +1 -1
  110. package/dist/cjs/transformations/index.js +73 -16
  111. package/dist/cjs/transformations/index.js.map +1 -1
  112. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  113. package/dist/cjs/utils.d.ts.map +1 -1
  114. package/dist/cjs/utils.js +52 -3
  115. package/dist/cjs/utils.js.map +1 -1
  116. package/dist/esm/count-effect.d.ts +7 -1
  117. package/dist/esm/count-effect.d.ts.map +1 -1
  118. package/dist/esm/count-effect.js +15 -5
  119. package/dist/esm/count-effect.js.map +1 -1
  120. package/dist/esm/fetchers/pg-client-import.d.ts +12 -0
  121. package/dist/esm/fetchers/pg-client-import.d.ts.map +1 -0
  122. package/dist/esm/fetchers/pg-client-import.js +12 -0
  123. package/dist/esm/fetchers/pg-client-import.js.map +1 -0
  124. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  125. package/dist/esm/fetchers/postgresql.js +5 -2
  126. package/dist/esm/fetchers/postgresql.js.map +1 -1
  127. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  128. package/dist/esm/fetchers/raw-query.js +2 -1
  129. package/dist/esm/fetchers/raw-query.js.map +1 -1
  130. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  131. package/dist/esm/fetchers/rest-api.js +3 -1
  132. package/dist/esm/fetchers/rest-api.js.map +1 -1
  133. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  134. package/dist/esm/fetchers/teleport.js +6 -3
  135. package/dist/esm/fetchers/teleport.js.map +1 -1
  136. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
  137. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  138. package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
  139. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  140. package/dist/esm/filter-utils.d.ts +19 -7
  141. package/dist/esm/filter-utils.d.ts.map +1 -1
  142. package/dist/esm/filter-utils.js +46 -12
  143. package/dist/esm/filter-utils.js.map +1 -1
  144. package/dist/esm/infinite-scroll.d.ts +25 -2
  145. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  146. package/dist/esm/infinite-scroll.js +142 -32
  147. package/dist/esm/infinite-scroll.js.map +1 -1
  148. package/dist/esm/list-sort-order.d.ts +14 -0
  149. package/dist/esm/list-sort-order.d.ts.map +1 -0
  150. package/dist/esm/list-sort-order.js +32 -0
  151. package/dist/esm/list-sort-order.js.map +1 -0
  152. package/dist/esm/pagination-controls.d.ts +22 -3
  153. package/dist/esm/pagination-controls.d.ts.map +1 -1
  154. package/dist/esm/pagination-controls.js +28 -2
  155. package/dist/esm/pagination-controls.js.map +1 -1
  156. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  157. package/dist/esm/pagination-plugin.js +271 -216
  158. package/dist/esm/pagination-plugin.js.map +1 -1
  159. package/dist/esm/product-price-sort.d.ts +0 -9
  160. package/dist/esm/product-price-sort.d.ts.map +1 -1
  161. package/dist/esm/product-price-sort.js +0 -11
  162. package/dist/esm/product-price-sort.js.map +1 -1
  163. package/dist/esm/range-control.d.ts +45 -0
  164. package/dist/esm/range-control.d.ts.map +1 -0
  165. package/dist/esm/range-control.js +137 -0
  166. package/dist/esm/range-control.js.map +1 -0
  167. package/dist/esm/row-order.d.ts +20 -0
  168. package/dist/esm/row-order.d.ts.map +1 -0
  169. package/dist/esm/row-order.js +41 -0
  170. package/dist/esm/row-order.js.map +1 -0
  171. package/dist/esm/search-input.d.ts +42 -0
  172. package/dist/esm/search-input.d.ts.map +1 -0
  173. package/dist/esm/search-input.js +129 -0
  174. package/dist/esm/search-input.js.map +1 -0
  175. package/dist/esm/sort-utils.d.ts +10 -0
  176. package/dist/esm/sort-utils.d.ts.map +1 -1
  177. package/dist/esm/sort-utils.js +20 -0
  178. package/dist/esm/sort-utils.js.map +1 -1
  179. package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
  180. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
  181. package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
  182. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
  183. package/dist/esm/transformations/blog-comments.d.ts +1 -1
  184. package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
  185. package/dist/esm/transformations/blog-comments.js +1 -1
  186. package/dist/esm/transformations/blog-comments.js.map +1 -1
  187. package/dist/esm/transformations/blog-post.d.ts +14 -0
  188. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  189. package/dist/esm/transformations/blog-post.js +4 -1
  190. package/dist/esm/transformations/blog-post.js.map +1 -1
  191. package/dist/esm/transformations/booking-item.d.ts +24 -0
  192. package/dist/esm/transformations/booking-item.d.ts.map +1 -0
  193. package/dist/esm/transformations/booking-item.js +23 -0
  194. package/dist/esm/transformations/booking-item.js.map +1 -0
  195. package/dist/esm/transformations/heading-anchors.d.ts +32 -8
  196. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
  197. package/dist/esm/transformations/heading-anchors.js +33 -9
  198. package/dist/esm/transformations/heading-anchors.js.map +1 -1
  199. package/dist/esm/transformations/index.d.ts +27 -13
  200. package/dist/esm/transformations/index.d.ts.map +1 -1
  201. package/dist/esm/transformations/index.js +72 -16
  202. package/dist/esm/transformations/index.js.map +1 -1
  203. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  204. package/dist/esm/utils.d.ts.map +1 -1
  205. package/dist/esm/utils.js +52 -3
  206. package/dist/esm/utils.js.map +1 -1
  207. package/package.json +5 -5
  208. package/src/count-effect.ts +31 -8
  209. package/src/fetchers/pg-client-import.ts +16 -0
  210. package/src/fetchers/postgresql.ts +25 -23
  211. package/src/fetchers/raw-query.ts +2 -1
  212. package/src/fetchers/rest-api.ts +3 -1
  213. package/src/fetchers/teleport.ts +22 -23
  214. package/src/fetchers/utils/browser-row-policy.ts +10 -20
  215. package/src/filter-utils.ts +102 -28
  216. package/src/infinite-scroll.ts +240 -79
  217. package/src/list-sort-order.ts +34 -0
  218. package/src/pagination-controls.ts +26 -3
  219. package/src/pagination-plugin.ts +370 -306
  220. package/src/product-price-sort.ts +0 -11
  221. package/src/range-control.ts +207 -0
  222. package/src/row-order.ts +55 -0
  223. package/src/search-input.ts +190 -0
  224. package/src/sort-utils.ts +25 -0
  225. package/src/transformations/blog-adjacent-posts.ts +89 -3
  226. package/src/transformations/blog-comments.ts +5 -5
  227. package/src/transformations/blog-post.ts +64 -13
  228. package/src/transformations/booking-item.ts +71 -0
  229. package/src/transformations/heading-anchors.ts +79 -16
  230. package/src/transformations/index.ts +104 -14
  231. package/src/utils.ts +74 -3
@@ -1,7 +1,13 @@
1
+ /* tslint:disable:function-constructor */
1
2
  import * as types from '@babel/types'
2
3
  import generator from '@babel/generator'
3
4
  import { ComponentStructure } from '@teleporthq/teleport-types'
4
5
  import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
6
+ import {
7
+ buildAccumulatingRequestMark,
8
+ buildAccumulatingResponseHandler,
9
+ getInfiniteScrollVars,
10
+ } from '../src/infinite-scroll'
5
11
  import {
6
12
  makeMultiMapperUidl,
7
13
  makeUidlNode,
@@ -11,9 +17,10 @@ import {
11
17
 
12
18
  const runPlugin = async (
13
19
  repeater: RepeaterOptions,
14
- controls: string[] = ['previous', 'next']
20
+ controls: string[] = ['previous', 'next'],
21
+ rootTag?: string
15
22
  ): Promise<string> => {
16
- const chunk = makeWidgetComponentChunk({ controls })
23
+ const chunk = makeWidgetComponentChunk({ controls, rootTag })
17
24
  const structure: ComponentStructure = {
18
25
  uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
19
26
  chunks: [chunk],
@@ -31,7 +38,7 @@ describe('pagination plugin — infinite scroll', () => {
31
38
  const code = await runPlugin({ infiniteScroll: true })
32
39
  const flat = collapse(code)
33
40
 
34
- expect(flat).toContain('const ds_0_accumRef = useRef({ sig: "", pages: {} })')
41
+ expect(flat).toContain('const ds_0_accumRef = useRef({ sig: "", pages: {}, latest: null })')
35
42
  expect(flat).toContain('const [ds_0_hasMoreFlag, setDs_0_hasMoreFlag] = useState(true)')
36
43
  // Two independent end-of-data signals: a short page, and the counted total.
37
44
  expect(flat).toContain(
@@ -42,12 +49,18 @@ describe('pagination plugin — infinite scroll', () => {
42
49
  // change must start a fresh list rather than interleave two result sets.
43
50
  expect(flat).toContain('const sig = JSON.stringify({ ...params, page: 0 })')
44
51
  expect(flat).toContain('if (ds_0_accumRef.current.sig !== sig)')
45
- expect(flat).toContain('ds_0_accumRef.current = { sig, pages: {} }')
52
+ expect(flat).toContain('ds_0_accumRef.current.pages = {}')
46
53
  expect(flat).toContain('ds_0_accumRef.current.pages[page] = rows')
54
+ // fetchData records the request the list stands on before making it.
55
+ expect(flat).toContain(
56
+ 'ds_0_accumRef.current.latest = { sig: JSON.stringify({ ...params, page: 0 }), page: Number(params.page) || 1 }'
57
+ )
47
58
  // A short page is the only end signal a source that cannot count gives.
48
59
  expect(flat).toContain('setDs_0_hasMoreFlag(rows.length >= 20)')
49
60
  // Numeric sort, so page 10 does not land between 1 and 2.
50
- expect(flat).toContain('.map(Number).sort((a, b) => a - b).reduce(')
61
+ expect(flat).toMatch(
62
+ /\.map\(Number\)\.filter\(\(?p\)? => p <= last\)\.sort\(\(a, b\) => a - b\)\.reduce\(/
63
+ )
51
64
  })
52
65
 
53
66
  it('keeps the page out of the provider key and suppresses initialData', async () => {
@@ -82,6 +95,30 @@ describe('pagination plugin — infinite scroll', () => {
82
95
  )
83
96
  })
84
97
 
98
+ it('builds the sentinel as an element its parent may hold', async () => {
99
+ // A <div> among a table's rows is moved out of the table by the parser:
100
+ // the server's markup and the client's tree then differ and hydration fails.
101
+ const inBody = collapse(
102
+ await runPlugin({ infiniteScroll: true }, ['previous', 'next'], 'tbody')
103
+ )
104
+ expect(inBody).toContain(
105
+ '<tr ref={ds_0_sentinelRef} aria-hidden="true" style={{ height: "1px" }}><td style={{ padding: 0, border: 0 }} /></tr>'
106
+ )
107
+ expect(inBody).not.toContain('<div ref={ds_0_sentinelRef}')
108
+
109
+ const inList = collapse(await runPlugin({ infiniteScroll: true }, ['previous', 'next'], 'ul'))
110
+ expect(inList).toContain(
111
+ '<li ref={ds_0_sentinelRef} aria-hidden="true" style={{ height: "1px", listStyle: "none" }} />'
112
+ )
113
+
114
+ const inParagraph = collapse(
115
+ await runPlugin({ infiniteScroll: true }, ['previous', 'next'], 'p')
116
+ )
117
+ expect(inParagraph).toContain(
118
+ '<span ref={ds_0_sentinelRef} aria-hidden="true" style={{ display: "block", height: "1px" }} />'
119
+ )
120
+ })
121
+
85
122
  it('wires the authored Load More button instead, with no observer', async () => {
86
123
  const code = await runPlugin({ infiniteScroll: true, infiniteScrollLoadMore: true }, [
87
124
  'load-more',
@@ -141,3 +178,115 @@ describe('pagination plugin — infinite scroll', () => {
141
178
  expect(code).not.toContain('setDs_0_page(page => Math.max(1, page - 1))')
142
179
  })
143
180
  })
181
+
182
+ /**
183
+ * The accumulator, executed: `fetchData` as the page emits it, answered in
184
+ * the orders a browser can produce. A sort (filter, search) change made on
185
+ * page 2 fires page 2 under the new query first, then page 1 once the page is
186
+ * reset — and `DataProvider` renders whichever answer lands last.
187
+ */
188
+ describe('the appending list, run', () => {
189
+ const PER_PAGE = 2
190
+
191
+ interface Deferred {
192
+ params: { page: number; sorts: string }
193
+ resolve: (rows: string[]) => void
194
+ result: Promise<string[]>
195
+ }
196
+
197
+ function bootList() {
198
+ const vars = getInfiniteScrollVars(0)
199
+ const mark = generator(types.program([buildAccumulatingRequestMark(vars)])).code
200
+ const handler = generator(buildAccumulatingResponseHandler(vars, PER_PAGE)).code
201
+ const accumRef = { current: { sig: '', pages: {}, latest: null } }
202
+ const hasMore: boolean[] = []
203
+ const fetchData = new Function(
204
+ 'ds_0_accumRef',
205
+ 'setDs_0_hasMoreFlag',
206
+ 'answer',
207
+ `return function fetchData(params) { ${mark}; return answer(params).then(${handler}) }`
208
+ )(
209
+ accumRef,
210
+ (value: boolean) => hasMore.push(value),
211
+ (params: Deferred['params']) => {
212
+ let resolve: (rows: string[]) => void = () => undefined
213
+ const answered = new Promise<{ data: string[] }>((done) => {
214
+ resolve = (rows) => done({ data: rows })
215
+ })
216
+ requests.push({ params, resolve, result: undefined as never })
217
+ return answered
218
+ }
219
+ ) as (params: Deferred['params']) => Promise<string[]>
220
+ const requests: Deferred[] = []
221
+ const request = (page: number, sorts: string): Deferred => {
222
+ const result = fetchData({ page, sorts })
223
+ const made = requests[requests.length - 1]
224
+ made.result = result
225
+ return made
226
+ }
227
+ return { request, hasMore }
228
+ }
229
+
230
+ const rowsOf = (sorts: string, page: number) => [`${sorts}${page}a`, `${sorts}${page}b`]
231
+
232
+ it('restarts at page 1 after a sort change on page 2, and loads page 2 on the next click', async () => {
233
+ const list = bootList()
234
+ const first = list.request(1, 'A')
235
+ first.resolve(rowsOf('A', 1))
236
+ const second = list.request(2, 'A')
237
+ second.resolve(rowsOf('A', 2))
238
+ expect(await second.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2)])
239
+
240
+ const stray = list.request(2, 'B')
241
+ const restart = list.request(1, 'B')
242
+ stray.resolve(rowsOf('B', 2))
243
+ // The stray page changes nothing: the list on show stays until page 1 answers.
244
+ expect(await stray.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2)])
245
+ restart.resolve(rowsOf('B', 1))
246
+ expect(await restart.result).toEqual(rowsOf('B', 1))
247
+
248
+ const next = list.request(2, 'B')
249
+ next.resolve(rowsOf('B', 2))
250
+ expect(await next.result).toEqual([...rowsOf('B', 1), ...rowsOf('B', 2)])
251
+ })
252
+
253
+ it('keeps the restarted list when the stray page answers last', async () => {
254
+ const list = bootList()
255
+ list.request(1, 'A').resolve(rowsOf('A', 1))
256
+ const second = list.request(2, 'A')
257
+ second.resolve(rowsOf('A', 2))
258
+ await second.result
259
+
260
+ const stray = list.request(2, 'B')
261
+ const restart = list.request(1, 'B')
262
+ restart.resolve(rowsOf('B', 1))
263
+ expect(await restart.result).toEqual(rowsOf('B', 1))
264
+ stray.resolve(rowsOf('B', 2))
265
+ expect(await stray.result).toEqual(rowsOf('B', 1))
266
+ })
267
+
268
+ it('never lets a late answer to an earlier query replace the list', async () => {
269
+ const list = bootList()
270
+ const before = list.request(1, 'A')
271
+ const after = list.request(1, 'B')
272
+ after.resolve(rowsOf('B', 1))
273
+ expect(await after.result).toEqual(rowsOf('B', 1))
274
+ before.resolve(rowsOf('A', 1))
275
+ expect(await before.result).toEqual(rowsOf('B', 1))
276
+ })
277
+
278
+ it('ends the list on a short page of the request it stands on only', async () => {
279
+ const list = bootList()
280
+ const stray = list.request(2, 'A')
281
+ const page1 = list.request(1, 'A')
282
+ stray.resolve(['A2a'])
283
+ await stray.result
284
+ expect(list.hasMore).toEqual([])
285
+ page1.resolve(rowsOf('A', 1))
286
+ await page1.result
287
+ const page2 = list.request(2, 'A')
288
+ page2.resolve(['A2a'])
289
+ expect(await page2.result).toEqual([...rowsOf('A', 1), 'A2a'])
290
+ expect(list.hasMore).toEqual([true, false])
291
+ })
292
+ })
@@ -0,0 +1,61 @@
1
+ import { parse } from '@babel/parser'
2
+ import { generateDataSourceFetcher } from '../src/data-source-fetchers'
3
+
4
+ /**
5
+ * How the SQL routes of the platform's listings order what a visitor's sort
6
+ * leaves tied or empty: a post without a reading time comes after the others
7
+ * whichever way the list is sorted ("longest read first" opened on them), and
8
+ * equal rows keep one order across pages.
9
+ *
10
+ * Pinned on the route the project generator ships (`generateDataSourceFetcher`),
11
+ * for both databases a blog can live in.
12
+ */
13
+
14
+ const ROUTES = [
15
+ ['teleport', { host: 'db', database: 'blog' }],
16
+ ['postgresql', { connectionString: 'postgres://user:pw@host:5432/db' }],
17
+ ] as const
18
+
19
+ const routeFor = (type: string, config: object, tableName: string): string =>
20
+ generateDataSourceFetcher(
21
+ { type, config } as unknown as Parameters<typeof generateDataSourceFetcher>[0],
22
+ tableName
23
+ )
24
+
25
+ const orderByLines = (route: string): string[] =>
26
+ route.split('\n').filter((line) => line.includes('ORDER BY') && line.includes('Clauses.join'))
27
+
28
+ describe.each(ROUTES)('the %s route of the blog listing', (type, config) => {
29
+ const route = routeFor(type, config, 'teleport_blog_posts')
30
+
31
+ it('parses as valid JavaScript', () => {
32
+ expect(() => parse(route, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
33
+ })
34
+
35
+ it('puts the rows without a value last, in both directions', () => {
36
+ expect(route).toMatch(/\$\{orderOf\(sort\)\} NULLS LAST`/)
37
+ expect(route).toMatch(/\$\{sortByDirection\} NULLS LAST\$\{tiebreak\}`/)
38
+ })
39
+
40
+ it('falls back to a plain ORDER BY when the database rejects it', () => {
41
+ expect(route).toMatch(/plainClauses = valid\.map\(\(sort\) => `[^`]*\$\{orderOf\(sort\)\}`\)/)
42
+ expect(route).toContain('const usedRewrittenSort = orderBySql !== plainOrderBySql')
43
+ expect(route).toContain('if (!usedRewrittenSort) throw sortError')
44
+ expect(route).toContain('baseSql + plainOrderBySql + sqlTail')
45
+ })
46
+
47
+ it('breaks ties by id, in the ORDER BY and in its fallback', () => {
48
+ expect(route).toContain(`fields.includes("id") ? '' : ", \\"id\\" " + direction`)
49
+ const lines = orderByLines(route)
50
+ expect(lines).toHaveLength(2)
51
+ lines.forEach((line) => expect(line).toMatch(/\$\{tiebreak\}/))
52
+ })
53
+ })
54
+
55
+ describe('a table no listing sorts for visitors', () => {
56
+ it('gets the empty-values-last order but no tiebreaker', () => {
57
+ const route = routeFor('teleport', { host: 'db', database: 'store' }, 'teleport_orders')
58
+ expect(route).toMatch(/\$\{orderOf\(sort\)\} NULLS LAST`/)
59
+ expect(route).toContain("const rowOrderTiebreak = () => ''")
60
+ })
61
+ })
@@ -0,0 +1,132 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+
4
+ /**
5
+ * A board's "No status" column lists the rows whose status is empty. The GUI
6
+ * sends that condition as `{ operand: '=', destination: null }` (and "has a
7
+ * status" as `'!='` + null), the one null shape every fetcher answers with
8
+ * `IS NULL` / `IS NOT NULL`. An operand the fetchers do not know is dropped
9
+ * with its null value — and a list whose only condition was dropped reads the
10
+ * whole table: every row in "No status", whose move button then overwrote the
11
+ * real statuses. Verdicts come from the SQL the executed route sends.
12
+ */
13
+
14
+ interface Captured {
15
+ sql: string
16
+ params: unknown[]
17
+ }
18
+
19
+ const bootRoute = (type: 'teleport' | 'postgresql') => {
20
+ const dataSource = {
21
+ id: 'ds-1',
22
+ name: 'db',
23
+ type,
24
+ config:
25
+ type === 'teleport'
26
+ ? {
27
+ host: 'teleporthq.secrets.TELEPORT_DB_HOST',
28
+ port: 5432,
29
+ user: 'teleporthq.secrets.TELEPORT_DB_USER',
30
+ password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
31
+ database: 'teleporthq.secrets.TELEPORT_DB_NAME',
32
+ selectedTables: {
33
+ team_events: { columns: [{ name: 'id' }, { name: 'priority' }] },
34
+ },
35
+ }
36
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
37
+ }
38
+ const code = generateDataSourceFetcherWithCore(dataSource as never, 'team_events', false, {})
39
+ .replace(
40
+ "import { Client, types as pgTypes } from 'pg'",
41
+ "const { Client, types: pgTypes } = require('pg')"
42
+ )
43
+ .replace(/^export default async function handler/m, 'async function handler')
44
+ .replace(/^export \{[^}]*\}\s*$/m, '')
45
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
46
+ .concat('\nmodule.exports = { handler: handler }')
47
+
48
+ const captured: Captured[] = []
49
+ class FakeClient {
50
+ public async connect(): Promise<void> {
51
+ return
52
+ }
53
+ public async end(): Promise<void> {
54
+ return
55
+ }
56
+ public async query(sql: string, params: unknown[] = []): Promise<{ rows: any[] }> {
57
+ captured.push({ sql, params })
58
+ return { rows: [] }
59
+ }
60
+ }
61
+ const moduleObj: { exports: any } = { exports: {} }
62
+ new Function('require', 'module', 'exports', 'process', 'console', code)(
63
+ (name: string) => {
64
+ if (name === 'pg') {
65
+ return { Client: FakeClient }
66
+ }
67
+ throw new Error('unexpected require: ' + name)
68
+ },
69
+ moduleObj,
70
+ moduleObj.exports,
71
+ { env: {} },
72
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
73
+ )
74
+
75
+ return async (filters: unknown): Promise<Captured> => {
76
+ captured.length = 0
77
+ let status = 0
78
+ const res = {
79
+ status(statusCode: number) {
80
+ status = statusCode
81
+ return this
82
+ },
83
+ json() {
84
+ return this
85
+ },
86
+ setHeader() {
87
+ return this
88
+ },
89
+ }
90
+ await moduleObj.exports.handler(
91
+ { method: 'GET', query: { filters: JSON.stringify(filters) }, headers: {} },
92
+ res
93
+ )
94
+ expect(status).toBe(200)
95
+ const select = captured.find((entry) => /FROM\s+"?team_events"?/i.test(entry.sql))
96
+ if (!select) {
97
+ throw new Error('the route never read team_events')
98
+ }
99
+ return select
100
+ }
101
+ }
102
+
103
+ // The domain → UIDL mapper's output for one condition in the inspector's group.
104
+ const condition = (operand: string, destination: unknown = null) => [
105
+ {
106
+ type: 'group',
107
+ operator: 'and',
108
+ children: [{ type: 'condition', source: 'priority', destination, operand }],
109
+ },
110
+ ]
111
+
112
+ describe.each(['teleport', 'postgresql'] as const)('the %s read route', (type) => {
113
+ const read = bootRoute(type)
114
+
115
+ it('answers "is empty" (= null) with IS NULL', async () => {
116
+ expect((await read(condition('='))).sql).toMatch(/WHERE\s+priority IS NULL/)
117
+ })
118
+
119
+ it('answers "is not empty" (!= null) with IS NOT NULL', async () => {
120
+ expect((await read(condition('!='))).sql).toMatch(/WHERE\s+priority IS NOT NULL/)
121
+ })
122
+
123
+ it('still binds a plain value as a parameter', async () => {
124
+ const select = await read(condition('=', 'high'))
125
+ expect(select.sql).toMatch(/WHERE\s+priority = \$1/)
126
+ expect(select.params).toEqual(expect.arrayContaining(['high']))
127
+ })
128
+
129
+ it('reads the whole table for an operand it does not know — why the mapper never sends one', async () => {
130
+ expect((await read(condition('is_null'))).sql).not.toMatch(/WHERE/)
131
+ })
132
+ })
@@ -0,0 +1,186 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkDefinition,
5
+ ChunkType,
6
+ ComponentStructure,
7
+ FileType,
8
+ } from '@teleporthq/teleport-types'
9
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
10
+ import {
11
+ makeUidlNode,
12
+ makeWidgetComponentChunk,
13
+ type RepeaterOptions,
14
+ } from './_helpers/pagination-widget-fixture'
15
+
16
+ /**
17
+ * A list filter can say "Category is Commercial OR Narrative" — an "any of"
18
+ * group. The paginated and searchable lists used to flatten that group into a
19
+ * flat list the fetcher joined with AND, so the published site showed the rows
20
+ * that were BOTH Commercial and Narrative: none. The group now reaches the
21
+ * fetcher as a group, in the browser fetch and in the build-time prefetch
22
+ * alike, and the fetcher's own `normalizeFilterTree` honours its operator
23
+ * (`filter-groups.test.ts`).
24
+ */
25
+
26
+ const condition = (source: string, destination: unknown, operand = '=') => ({
27
+ type: 'condition',
28
+ source,
29
+ destination,
30
+ operand,
31
+ })
32
+
33
+ const group = (operator: 'and' | 'or', children: unknown[]) => ({
34
+ type: 'group',
35
+ operator,
36
+ children,
37
+ })
38
+
39
+ const stateRef = (id: string) => ({ type: 'dynamic', content: { referenceType: 'state', id } })
40
+
41
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
42
+
43
+ // The page's `getStaticProps` as the main data-source plugin leaves it: a
44
+ // `Promise.all` of prefetches and a `props` object, which the pagination plugin
45
+ // extends with the paginated list's first page and its count.
46
+ const makeGetStaticPropsChunk = (): ChunkDefinition => ({
47
+ name: 'getStaticProps',
48
+ type: ChunkType.AST,
49
+ fileType: FileType.JS,
50
+ linkAfter: ['jsx-component'],
51
+ meta: {},
52
+ content: types.exportNamedDeclaration(
53
+ types.functionDeclaration(
54
+ types.identifier('getStaticProps'),
55
+ [types.identifier('context')],
56
+ types.blockStatement([
57
+ types.tryStatement(
58
+ types.blockStatement([
59
+ types.variableDeclaration('const', [
60
+ types.variableDeclarator(
61
+ types.arrayPattern([]),
62
+ types.awaitExpression(
63
+ types.callExpression(
64
+ types.memberExpression(types.identifier('Promise'), types.identifier('all')),
65
+ [types.arrayExpression([])]
66
+ )
67
+ )
68
+ ),
69
+ ]),
70
+ types.returnStatement(
71
+ types.objectExpression([
72
+ types.objectProperty(types.identifier('props'), types.objectExpression([])),
73
+ ])
74
+ ),
75
+ ]),
76
+ types.catchClause(
77
+ types.identifier('error'),
78
+ types.blockStatement([
79
+ types.returnStatement(
80
+ types.objectExpression([
81
+ types.objectProperty(types.identifier('props'), types.objectExpression([])),
82
+ ])
83
+ ),
84
+ ])
85
+ )
86
+ ),
87
+ ]),
88
+ false,
89
+ true
90
+ )
91
+ ),
92
+ })
93
+
94
+ const runPlugin = async (repeater: RepeaterOptions) => {
95
+ const chunk = makeWidgetComponentChunk({ controls: ['previous', 'next'] })
96
+ const staticProps = makeGetStaticPropsChunk()
97
+ const structure: ComponentStructure = {
98
+ uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
99
+ chunks: [chunk, staticProps],
100
+ dependencies: {},
101
+ options: { dataSources: {}, extractedResources: {} },
102
+ } as never
103
+ await createNextArrayMapperPaginationPlugin()(structure)
104
+ return {
105
+ component: collapse(generator(chunk.content as types.Node).code),
106
+ staticProps: collapse(generator(staticProps.content as types.Node).code),
107
+ }
108
+ }
109
+
110
+ const OR_GROUP = [
111
+ group('or', [condition('Category', 'Commercial'), condition('Category', 'Narrative')]),
112
+ ]
113
+
114
+ const expectOrGroup = (code: string) => {
115
+ expect(code).toContain('type: "group"')
116
+ expect(code).toContain('operator: "or"')
117
+ expect(code).toContain('source: "Category", destination: "Commercial", operand: "="')
118
+ expect(code).toContain('source: "Category", destination: "Narrative", operand: "="')
119
+ // The two conditions sit INSIDE the group's children, never beside it.
120
+ expect(code).toMatch(/children: \[[^\]]*Commercial[^\]]*Narrative[^\]]*\]/)
121
+ }
122
+
123
+ describe('pagination plugin — filter groups', () => {
124
+ it('a paginated list keeps an or-group as an or-group in its browser fetch', async () => {
125
+ const { component } = await runPlugin({ paginated: true, filters: OR_GROUP })
126
+ expectOrGroup(component)
127
+ })
128
+
129
+ it('a searchable list keeps it too', async () => {
130
+ const { component } = await runPlugin({
131
+ paginated: true,
132
+ searchEnabled: true,
133
+ filters: OR_GROUP,
134
+ })
135
+ expectOrGroup(component)
136
+ })
137
+
138
+ it('the build-time prefetch and its count carry the same group', async () => {
139
+ const { staticProps } = await runPlugin({ paginated: true, filters: OR_GROUP })
140
+ expect(staticProps).toContain('page: 1')
141
+ expectOrGroup(staticProps)
142
+ // Once for the first page, once for the count.
143
+ expect(staticProps.match(/operator: "or"/g)).toHaveLength(2)
144
+ })
145
+
146
+ it('the runtime drop of empty destinations lets a group through and prunes inside it', async () => {
147
+ const { component } = await runPlugin({
148
+ paginated: true,
149
+ filters: [
150
+ group('or', [
151
+ condition('Category', 'Commercial'),
152
+ condition('Category', stateRef('picked')),
153
+ ]),
154
+ ],
155
+ })
156
+ // Every level is filtered; the predicate keeps groups themselves.
157
+ expect(component).toContain('__f.type === "group" ||')
158
+ expect(component).toMatch(/children: \[.*\]\.filter\(__f =>/)
159
+ // The state-bound leaf is still a dependency of the fetch params.
160
+ expect(component).toContain('picked')
161
+ })
162
+
163
+ it('the prefetch prunes a dynamic leaf but keeps the group around its static sibling', async () => {
164
+ const { staticProps } = await runPlugin({
165
+ paginated: true,
166
+ filters: [
167
+ group('or', [
168
+ condition('Category', 'Commercial'),
169
+ condition('Category', stateRef('picked')),
170
+ ]),
171
+ ],
172
+ })
173
+ expect(staticProps).toContain('operator: "or"')
174
+ expect(staticProps).toContain('destination: "Commercial"')
175
+ expect(staticProps).not.toContain('picked')
176
+ })
177
+
178
+ it('a legacy flat condition list still works', async () => {
179
+ const { component } = await runPlugin({
180
+ paginated: true,
181
+ filters: [{ source: 'Category', destination: 'Commercial', operand: '=' }],
182
+ })
183
+ expect(component).toContain('source: "Category", destination: "Commercial", operand: "="')
184
+ expect(component).not.toContain('type: "group"')
185
+ })
186
+ })
@@ -0,0 +1,73 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+
4
+ /**
5
+ * A Postgres DATE is a calendar day. node-pg reads it as midnight in the
6
+ * server's time zone, so a server east of UTC served `2026-10-09` as
7
+ * `2026-10-08T21:00:00.000Z`: the list showed the day before, and an edit form
8
+ * that loaded the row saved that day back. Every Postgres fetcher keeps the
9
+ * day as its own text; a stand-in driver without `types` still loads.
10
+ */
11
+
12
+ type ParserSpy = Array<{ oid: number; parse: (value: string) => unknown }>
13
+
14
+ const bootFetcher = (type: 'teleport' | 'postgresql', withTypes: boolean): ParserSpy => {
15
+ const dataSource = {
16
+ id: 'ds-1',
17
+ name: 'db',
18
+ type,
19
+ config:
20
+ type === 'teleport'
21
+ ? {
22
+ host: 'teleporthq.secrets.TELEPORT_DB_HOST',
23
+ port: 5432,
24
+ user: 'teleporthq.secrets.TELEPORT_DB_USER',
25
+ password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
26
+ database: 'teleporthq.secrets.TELEPORT_DB_NAME',
27
+ selectedTables: { orders: { columns: [{ name: 'id' }, { name: 'due' }] } },
28
+ }
29
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
30
+ }
31
+ const code = generateDataSourceFetcherWithCore(dataSource as never, 'orders', false, {})
32
+ expect(code.startsWith("import { Client, types as pgTypes } from 'pg'")).toBe(true)
33
+ const runnable = code
34
+ .replace(
35
+ "import { Client, types as pgTypes } from 'pg'",
36
+ "const { Client, types: pgTypes } = require('pg')"
37
+ )
38
+ .replace(/^export default async function handler/m, 'async function handler')
39
+ .replace(/^export \{[^}]*\}\s*$/m, '')
40
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
41
+ const registered: ParserSpy = []
42
+ const types = {
43
+ setTypeParser: (oid: number, parse: (value: string) => unknown) =>
44
+ registered.push({ oid, parse }),
45
+ }
46
+ new Function('require', 'module', 'exports', 'process', 'console', runnable)(
47
+ (name: string) => {
48
+ if (name === 'pg') {
49
+ return withTypes ? { Client: class {}, types } : { Client: class {} }
50
+ }
51
+ throw new Error('unexpected require: ' + name)
52
+ },
53
+ { exports: {} },
54
+ {},
55
+ { env: {} },
56
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
57
+ )
58
+ return registered
59
+ }
60
+
61
+ describe.each(['teleport', 'postgresql'] as const)('%s fetcher DATE columns', (type) => {
62
+ it('keeps a DATE as the day it names', () => {
63
+ const registered = bootFetcher(type, true)
64
+ const date = registered.find(({ oid }) => oid === 1082)
65
+ expect(date).toBeDefined()
66
+ expect(date?.parse('2026-10-09')).toBe('2026-10-09')
67
+ expect(registered.map(({ oid }) => oid)).toEqual([1082])
68
+ })
69
+
70
+ it('loads with a driver that has no type registry', () => {
71
+ expect(bootFetcher(type, false)).toEqual([])
72
+ })
73
+ })