@teleporthq/teleport-plugin-next-data-source 0.43.75 → 0.44.1

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 (199) hide show
  1. package/__tests__/_helpers/first-page-runtime.ts +184 -0
  2. package/__tests__/_helpers/pagination-widget-fixture.ts +154 -5
  3. package/__tests__/booking-item-transform.test.ts +120 -0
  4. package/__tests__/browser-row-policy.test.ts +210 -4
  5. package/__tests__/data-source-request-locale.test.ts +4 -3
  6. package/__tests__/fetchers.test.ts +7 -3
  7. package/__tests__/fixed-list-sort.test.ts +1 -1
  8. package/__tests__/infinite-scroll.test.ts +363 -9
  9. package/__tests__/list-sort-order.test.ts +61 -0
  10. package/__tests__/null-filter-where.test.ts +132 -0
  11. package/__tests__/page-data-visitor-read.test.ts +196 -0
  12. package/__tests__/pg-date-parser.test.ts +73 -0
  13. package/__tests__/plain-list-sort.test.ts +2 -2
  14. package/__tests__/plain-list-starting-sort.test.ts +323 -0
  15. package/__tests__/product-price-sort.ts +11 -6
  16. package/__tests__/range-control.test.ts +159 -0
  17. package/__tests__/refetch-loading-state.test.ts +158 -2
  18. package/__tests__/search-input-marker.test.ts +86 -0
  19. package/__tests__/search-url-sync.test.ts +34 -8
  20. package/__tests__/starting-sort-first-page.test.ts +285 -0
  21. package/__tests__/table-read-route-protected-tables.test.ts +33 -3
  22. package/dist/cjs/cache/ast.d.ts +2 -0
  23. package/dist/cjs/cache/ast.d.ts.map +1 -1
  24. package/dist/cjs/cache/ast.js +5 -2
  25. package/dist/cjs/cache/ast.js.map +1 -1
  26. package/dist/cjs/count-effect.d.ts +7 -1
  27. package/dist/cjs/count-effect.d.ts.map +1 -1
  28. package/dist/cjs/count-effect.js +15 -5
  29. package/dist/cjs/count-effect.js.map +1 -1
  30. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  31. package/dist/cjs/data-source-fetchers.js +10 -2
  32. package/dist/cjs/data-source-fetchers.js.map +1 -1
  33. package/dist/cjs/fetchers/pg-client-import.d.ts +12 -0
  34. package/dist/cjs/fetchers/pg-client-import.d.ts.map +1 -0
  35. package/dist/cjs/fetchers/pg-client-import.js +15 -0
  36. package/dist/cjs/fetchers/pg-client-import.js.map +1 -0
  37. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  38. package/dist/cjs/fetchers/postgresql.js +3 -1
  39. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  40. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  41. package/dist/cjs/fetchers/raw-query.js +2 -1
  42. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  43. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  44. package/dist/cjs/fetchers/teleport.js +4 -2
  45. package/dist/cjs/fetchers/teleport.js.map +1 -1
  46. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  47. package/dist/cjs/fetchers/utils/browser-row-policy.js +5 -1
  48. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  49. package/dist/cjs/index.d.ts.map +1 -1
  50. package/dist/cjs/index.js +14 -2
  51. package/dist/cjs/index.js.map +1 -1
  52. package/dist/cjs/infinite-scroll.d.ts +89 -4
  53. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  54. package/dist/cjs/infinite-scroll.js +243 -33
  55. package/dist/cjs/infinite-scroll.js.map +1 -1
  56. package/dist/cjs/list-sort-order.d.ts +14 -0
  57. package/dist/cjs/list-sort-order.d.ts.map +1 -0
  58. package/dist/cjs/list-sort-order.js +36 -0
  59. package/dist/cjs/list-sort-order.js.map +1 -0
  60. package/dist/cjs/loading-state.d.ts +23 -2
  61. package/dist/cjs/loading-state.d.ts.map +1 -1
  62. package/dist/cjs/loading-state.js +50 -15
  63. package/dist/cjs/loading-state.js.map +1 -1
  64. package/dist/cjs/pagination-controls.d.ts +22 -3
  65. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  66. package/dist/cjs/pagination-controls.js +29 -3
  67. package/dist/cjs/pagination-controls.js.map +1 -1
  68. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  69. package/dist/cjs/pagination-plugin.js +336 -164
  70. package/dist/cjs/pagination-plugin.js.map +1 -1
  71. package/dist/cjs/range-control.d.ts +45 -0
  72. package/dist/cjs/range-control.d.ts.map +1 -0
  73. package/dist/cjs/range-control.js +165 -0
  74. package/dist/cjs/range-control.js.map +1 -0
  75. package/dist/cjs/row-order.d.ts +3 -2
  76. package/dist/cjs/row-order.d.ts.map +1 -1
  77. package/dist/cjs/row-order.js +5 -4
  78. package/dist/cjs/row-order.js.map +1 -1
  79. package/dist/cjs/search-input.d.ts +42 -0
  80. package/dist/cjs/search-input.d.ts.map +1 -0
  81. package/dist/cjs/search-input.js +158 -0
  82. package/dist/cjs/search-input.js.map +1 -0
  83. package/dist/cjs/sort-utils.d.ts +53 -1
  84. package/dist/cjs/sort-utils.d.ts.map +1 -1
  85. package/dist/cjs/sort-utils.js +105 -13
  86. package/dist/cjs/sort-utils.js.map +1 -1
  87. package/dist/cjs/transformations/booking-item.d.ts +24 -0
  88. package/dist/cjs/transformations/booking-item.d.ts.map +1 -0
  89. package/dist/cjs/transformations/booking-item.js +27 -0
  90. package/dist/cjs/transformations/booking-item.js.map +1 -0
  91. package/dist/cjs/transformations/index.d.ts +4 -2
  92. package/dist/cjs/transformations/index.d.ts.map +1 -1
  93. package/dist/cjs/transformations/index.js +14 -5
  94. package/dist/cjs/transformations/index.js.map +1 -1
  95. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  96. package/dist/cjs/utils.d.ts +3 -2
  97. package/dist/cjs/utils.d.ts.map +1 -1
  98. package/dist/cjs/utils.js +48 -18
  99. package/dist/cjs/utils.js.map +1 -1
  100. package/dist/esm/cache/ast.d.ts +2 -0
  101. package/dist/esm/cache/ast.d.ts.map +1 -1
  102. package/dist/esm/cache/ast.js +3 -1
  103. package/dist/esm/cache/ast.js.map +1 -1
  104. package/dist/esm/count-effect.d.ts +7 -1
  105. package/dist/esm/count-effect.d.ts.map +1 -1
  106. package/dist/esm/count-effect.js +15 -5
  107. package/dist/esm/count-effect.js.map +1 -1
  108. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  109. package/dist/esm/data-source-fetchers.js +10 -2
  110. package/dist/esm/data-source-fetchers.js.map +1 -1
  111. package/dist/esm/fetchers/pg-client-import.d.ts +12 -0
  112. package/dist/esm/fetchers/pg-client-import.d.ts.map +1 -0
  113. package/dist/esm/fetchers/pg-client-import.js +12 -0
  114. package/dist/esm/fetchers/pg-client-import.js.map +1 -0
  115. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  116. package/dist/esm/fetchers/postgresql.js +3 -1
  117. package/dist/esm/fetchers/postgresql.js.map +1 -1
  118. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  119. package/dist/esm/fetchers/raw-query.js +2 -1
  120. package/dist/esm/fetchers/raw-query.js.map +1 -1
  121. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  122. package/dist/esm/fetchers/teleport.js +4 -2
  123. package/dist/esm/fetchers/teleport.js.map +1 -1
  124. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  125. package/dist/esm/fetchers/utils/browser-row-policy.js +6 -2
  126. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  127. package/dist/esm/index.d.ts.map +1 -1
  128. package/dist/esm/index.js +14 -2
  129. package/dist/esm/index.js.map +1 -1
  130. package/dist/esm/infinite-scroll.d.ts +89 -4
  131. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  132. package/dist/esm/infinite-scroll.js +237 -32
  133. package/dist/esm/infinite-scroll.js.map +1 -1
  134. package/dist/esm/list-sort-order.d.ts +14 -0
  135. package/dist/esm/list-sort-order.d.ts.map +1 -0
  136. package/dist/esm/list-sort-order.js +32 -0
  137. package/dist/esm/list-sort-order.js.map +1 -0
  138. package/dist/esm/loading-state.d.ts +23 -2
  139. package/dist/esm/loading-state.d.ts.map +1 -1
  140. package/dist/esm/loading-state.js +50 -15
  141. package/dist/esm/loading-state.js.map +1 -1
  142. package/dist/esm/pagination-controls.d.ts +22 -3
  143. package/dist/esm/pagination-controls.d.ts.map +1 -1
  144. package/dist/esm/pagination-controls.js +28 -2
  145. package/dist/esm/pagination-controls.js.map +1 -1
  146. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  147. package/dist/esm/pagination-plugin.js +340 -168
  148. package/dist/esm/pagination-plugin.js.map +1 -1
  149. package/dist/esm/range-control.d.ts +45 -0
  150. package/dist/esm/range-control.d.ts.map +1 -0
  151. package/dist/esm/range-control.js +137 -0
  152. package/dist/esm/range-control.js.map +1 -0
  153. package/dist/esm/row-order.d.ts +3 -2
  154. package/dist/esm/row-order.d.ts.map +1 -1
  155. package/dist/esm/row-order.js +5 -4
  156. package/dist/esm/row-order.js.map +1 -1
  157. package/dist/esm/search-input.d.ts +42 -0
  158. package/dist/esm/search-input.d.ts.map +1 -0
  159. package/dist/esm/search-input.js +129 -0
  160. package/dist/esm/search-input.js.map +1 -0
  161. package/dist/esm/sort-utils.d.ts +53 -1
  162. package/dist/esm/sort-utils.d.ts.map +1 -1
  163. package/dist/esm/sort-utils.js +100 -12
  164. package/dist/esm/sort-utils.js.map +1 -1
  165. package/dist/esm/transformations/booking-item.d.ts +24 -0
  166. package/dist/esm/transformations/booking-item.d.ts.map +1 -0
  167. package/dist/esm/transformations/booking-item.js +23 -0
  168. package/dist/esm/transformations/booking-item.js.map +1 -0
  169. package/dist/esm/transformations/index.d.ts +4 -2
  170. package/dist/esm/transformations/index.d.ts.map +1 -1
  171. package/dist/esm/transformations/index.js +14 -5
  172. package/dist/esm/transformations/index.js.map +1 -1
  173. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  174. package/dist/esm/utils.d.ts +3 -2
  175. package/dist/esm/utils.d.ts.map +1 -1
  176. package/dist/esm/utils.js +49 -19
  177. package/dist/esm/utils.js.map +1 -1
  178. package/package.json +5 -5
  179. package/src/cache/ast.ts +4 -1
  180. package/src/count-effect.ts +31 -8
  181. package/src/data-source-fetchers.ts +33 -14
  182. package/src/fetchers/pg-client-import.ts +16 -0
  183. package/src/fetchers/postgresql.ts +15 -9
  184. package/src/fetchers/raw-query.ts +2 -1
  185. package/src/fetchers/teleport.ts +19 -10
  186. package/src/fetchers/utils/browser-row-policy.ts +79 -5
  187. package/src/index.ts +8 -2
  188. package/src/infinite-scroll.ts +420 -81
  189. package/src/list-sort-order.ts +34 -0
  190. package/src/loading-state.ts +85 -13
  191. package/src/pagination-controls.ts +26 -3
  192. package/src/pagination-plugin.ts +532 -218
  193. package/src/range-control.ts +207 -0
  194. package/src/row-order.ts +5 -4
  195. package/src/search-input.ts +190 -0
  196. package/src/sort-utils.ts +164 -21
  197. package/src/transformations/booking-item.ts +71 -0
  198. package/src/transformations/index.ts +25 -2
  199. package/src/utils.ts +55 -18
@@ -1,8 +1,16 @@
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'
5
6
  import {
7
+ buildAccumulatingRequestMark,
8
+ buildAccumulatingResponseHandler,
9
+ buildFirstPageSeedEffect,
10
+ getInfiniteScrollVars,
11
+ } from '../src/infinite-scroll'
12
+ import {
13
+ makeGetStaticPropsChunk,
6
14
  makeMultiMapperUidl,
7
15
  makeUidlNode,
8
16
  makeWidgetComponentChunk,
@@ -11,9 +19,10 @@ import {
11
19
 
12
20
  const runPlugin = async (
13
21
  repeater: RepeaterOptions,
14
- controls: string[] = ['previous', 'next']
22
+ controls: string[] = ['previous', 'next'],
23
+ rootTag?: string
15
24
  ): Promise<string> => {
16
- const chunk = makeWidgetComponentChunk({ controls })
25
+ const chunk = makeWidgetComponentChunk({ controls, rootTag })
17
26
  const structure: ComponentStructure = {
18
27
  uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
19
28
  chunks: [chunk],
@@ -31,7 +40,7 @@ describe('pagination plugin — infinite scroll', () => {
31
40
  const code = await runPlugin({ infiniteScroll: true })
32
41
  const flat = collapse(code)
33
42
 
34
- expect(flat).toContain('const ds_0_accumRef = useRef({ sig: "", pages: {} })')
43
+ expect(flat).toContain('const ds_0_accumRef = useRef({ sig: "", pages: {}, latest: null })')
35
44
  expect(flat).toContain('const [ds_0_hasMoreFlag, setDs_0_hasMoreFlag] = useState(true)')
36
45
  // Two independent end-of-data signals: a short page, and the counted total.
37
46
  expect(flat).toContain(
@@ -42,22 +51,32 @@ describe('pagination plugin — infinite scroll', () => {
42
51
  // change must start a fresh list rather than interleave two result sets.
43
52
  expect(flat).toContain('const sig = JSON.stringify({ ...params, page: 0 })')
44
53
  expect(flat).toContain('if (ds_0_accumRef.current.sig !== sig)')
45
- expect(flat).toContain('ds_0_accumRef.current = { sig, pages: {} }')
54
+ expect(flat).toContain('ds_0_accumRef.current.pages = {}')
46
55
  expect(flat).toContain('ds_0_accumRef.current.pages[page] = rows')
56
+ // fetchData records the request the list stands on before making it.
57
+ expect(flat).toContain(
58
+ 'ds_0_accumRef.current.latest = { sig: JSON.stringify({ ...params, page: 0 }), page: Number(params.page) || 1 }'
59
+ )
47
60
  // A short page is the only end signal a source that cannot count gives.
48
61
  expect(flat).toContain('setDs_0_hasMoreFlag(rows.length >= 20)')
49
62
  // Numeric sort, so page 10 does not land between 1 and 2.
50
- expect(flat).toContain('.map(Number).sort((a, b) => a - b).reduce(')
63
+ expect(flat).toMatch(
64
+ /\.map\(Number\)\.filter\(\(?p\)? => p <= last\)\.sort\(\(a, b\) => a - b\)\.reduce\(/
65
+ )
51
66
  })
52
67
 
53
- it('keeps the page out of the provider key and suppresses initialData', async () => {
68
+ it('keeps the page out of the provider key, and a component fetches its own page 1', async () => {
54
69
  const code = await runPlugin({ infiniteScroll: true })
55
70
 
56
- // Remounting per appended page would throw away the accumulated rows...
71
+ // Remounting per appended page would throw away the accumulated rows.
57
72
  expect(code).not.toContain('ds_0_page}`')
58
- // ...and initialData would make DataProvider skip the fetch that fills the
59
- // accumulator with page 1.
73
+ // A component has no `getStaticProps` rows to start from, so its provider
74
+ // fetches page 1 — and that answer fills the accumulator. This used to hold
75
+ // for every appending list, pages included: the server HTML then carried
76
+ // the loading state although the page's props held the rows. A page now
77
+ // starts from them (next describe).
60
78
  expect(code).not.toContain('initialData')
79
+ expect(code).not.toContain('ds_0_firstPage')
61
80
  })
62
81
 
63
82
  it('observes a sentinel after the list when there is no Load More button', async () => {
@@ -82,6 +101,30 @@ describe('pagination plugin — infinite scroll', () => {
82
101
  )
83
102
  })
84
103
 
104
+ it('builds the sentinel as an element its parent may hold', async () => {
105
+ // A <div> among a table's rows is moved out of the table by the parser:
106
+ // the server's markup and the client's tree then differ and hydration fails.
107
+ const inBody = collapse(
108
+ await runPlugin({ infiniteScroll: true }, ['previous', 'next'], 'tbody')
109
+ )
110
+ expect(inBody).toContain(
111
+ '<tr ref={ds_0_sentinelRef} aria-hidden="true" style={{ height: "1px" }}><td style={{ padding: 0, border: 0 }} /></tr>'
112
+ )
113
+ expect(inBody).not.toContain('<div ref={ds_0_sentinelRef}')
114
+
115
+ const inList = collapse(await runPlugin({ infiniteScroll: true }, ['previous', 'next'], 'ul'))
116
+ expect(inList).toContain(
117
+ '<li ref={ds_0_sentinelRef} aria-hidden="true" style={{ height: "1px", listStyle: "none" }} />'
118
+ )
119
+
120
+ const inParagraph = collapse(
121
+ await runPlugin({ infiniteScroll: true }, ['previous', 'next'], 'p')
122
+ )
123
+ expect(inParagraph).toContain(
124
+ '<span ref={ds_0_sentinelRef} aria-hidden="true" style={{ display: "block", height: "1px" }} />'
125
+ )
126
+ })
127
+
85
128
  it('wires the authored Load More button instead, with no observer', async () => {
86
129
  const code = await runPlugin({ infiniteScroll: true, infiniteScrollLoadMore: true }, [
87
130
  'load-more',
@@ -141,3 +184,314 @@ describe('pagination plugin — infinite scroll', () => {
141
184
  expect(code).not.toContain('setDs_0_page(page => Math.max(1, page - 1))')
142
185
  })
143
186
  })
187
+
188
+ /**
189
+ * On a page, `getStaticProps` has already fetched the list's first page. The
190
+ * list starts from it — in the server HTML, and as page 1 of the accumulator —
191
+ * instead of painting its loading state and fetching the same rows again.
192
+ */
193
+ describe('pagination plugin — infinite scroll on a page', () => {
194
+ const runPagePlugin = async (
195
+ repeater: RepeaterOptions
196
+ ): Promise<{ component: string; staticProps: string }> => {
197
+ const chunk = makeWidgetComponentChunk({ controls: ['load-more'] })
198
+ const staticProps = makeGetStaticPropsChunk()
199
+ const structure: ComponentStructure = {
200
+ uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
201
+ chunks: [chunk, staticProps],
202
+ dependencies: {},
203
+ options: { dataSources: {}, extractedResources: {} },
204
+ } as never
205
+ await createNextArrayMapperPaginationPlugin()(structure)
206
+ return {
207
+ component: collapse(generator(chunk.content as types.Node).code),
208
+ staticProps: collapse(generator(staticProps.content as types.Node).code),
209
+ }
210
+ }
211
+
212
+ const stateFilter = {
213
+ type: 'condition',
214
+ source: 'category',
215
+ destination: { type: 'dynamic', content: { referenceType: 'state', id: 'selectedCategory' } },
216
+ operand: '=',
217
+ }
218
+
219
+ it('starts from the rows its page fetched, frozen for the life of the page', async () => {
220
+ const { component, staticProps } = await runPagePlugin({ infiniteScroll: true })
221
+
222
+ // The rows the page's getStaticProps fetched for this list...
223
+ expect(staticProps).toContain('items_ds_0: items_ds_0')
224
+ // ...are what the provider starts from, so the server HTML holds them. The
225
+ // value never moves with the page: the provider copies a changed
226
+ // `initialData` over what it shows, which would wipe the appended rows.
227
+ expect(component).toContain('initialData={ds_0_firstPage}')
228
+ expect(component).toContain(
229
+ 'const ds_0_firstPage = useMemo(() => props?.items_ds_0?.length > 0 ? props?.items_ds_0 : undefined, [])'
230
+ )
231
+ expect(component).not.toMatch(/initialData=\{ds_0_page === 1/)
232
+ })
233
+
234
+ it('files the rows as page 1 of the accumulator, under the signature fetchData computes', async () => {
235
+ const { component } = await runPagePlugin({ infiniteScroll: true })
236
+
237
+ // One params object for the provider and the signature.
238
+ expect(component).toContain(
239
+ 'const ds_0_params = useMemo(() => ({ page: ds_0_page, perPage: 20 }), [ds_0_page])'
240
+ )
241
+ expect(component).toContain('params={ds_0_params}')
242
+ expect(component).toContain(
243
+ 'useEffect(() => { if (ds_0_firstPage === undefined) return; const rows = ds_0_firstPage || []; const sig = JSON.stringify({ ...ds_0_params, page: 0 }); ds_0_accumRef.current = { sig, pages: { 1: rows }, latest: { sig, page: 1 } }; setDs_0_hasMoreFlag(rows.length >= 20); }, [ds_0_firstPage]);'
244
+ )
245
+ // Declared before anything reads them.
246
+ expect(component.indexOf('const ds_0_params =')).toBeLessThan(
247
+ component.indexOf('const ds_0_firstPage =')
248
+ )
249
+ expect(component.indexOf('const ds_0_firstPage =')).toBeLessThan(
250
+ component.indexOf('if (ds_0_firstPage === undefined)')
251
+ )
252
+ expect(component.indexOf('const ds_0_accumRef =')).toBeLessThan(
253
+ component.indexOf('if (ds_0_firstPage === undefined)')
254
+ )
255
+ })
256
+
257
+ it('takes the rows only while no search is applied, frozen per query', async () => {
258
+ const { component } = await runPagePlugin({ infiniteScroll: true, searchEnabled: true })
259
+
260
+ // The provider remounts per query (its key); the first page is decided once
261
+ // per mount, with the query and filters as they stand then.
262
+ // tslint:disable-next-line:no-invalid-template-strings
263
+ expect(component).toContain('key={`items-${ds_0_state.debouncedQuery}`}')
264
+ expect(component).toContain(
265
+ 'const ds_0_firstPage = useMemo(() => !ds_0_state.debouncedQuery && props?.items_ds_0?.length > 0 ? props?.items_ds_0 : undefined, [ds_0_state.debouncedQuery])'
266
+ )
267
+ expect(component).toContain('initialData={ds_0_firstPage}')
268
+ expect(component).toContain('const sig = JSON.stringify({ ...ds_0_params, page: 0 })')
269
+ })
270
+
271
+ it('does not take them while a filter is set', async () => {
272
+ const { component } = await runPagePlugin({ infiniteScroll: true, filters: [stateFilter] })
273
+
274
+ // The build-time fetch knows no visitor's filter: the same guard as the
275
+ // paged lists.
276
+ expect(component).toContain(
277
+ 'const ds_0_firstPage = useMemo(() => !selectedCategory && props?.items_ds_0?.length > 0 ? props?.items_ds_0 : undefined, [])'
278
+ )
279
+ })
280
+
281
+ it('reads the rows inside a payload that wraps them', async () => {
282
+ const { component } = await runPagePlugin({
283
+ infiniteScroll: true,
284
+ dataSourceType: 'rest-api',
285
+ source: 'items?.templates || []',
286
+ })
287
+
288
+ expect(component).toContain(
289
+ 'useMemo(() => props?.items_ds_0?.templates?.length > 0 ? props?.items_ds_0 : undefined, [])'
290
+ )
291
+ expect(component).toContain('const rows = ds_0_firstPage?.templates || [];')
292
+ })
293
+
294
+ it('reuses the params a cached list already hoisted', async () => {
295
+ const { component } = await runPagePlugin({
296
+ infiniteScroll: true,
297
+ cache: { enabled: true, ttlSeconds: 300, client: true, server: true },
298
+ })
299
+
300
+ expect(component.match(/const ds_0_params = /g)).toHaveLength(1)
301
+ expect(component).toContain('initialData={ds_0_firstPage}')
302
+ expect(component).toContain('const sig = JSON.stringify({ ...ds_0_params, page: 0 })')
303
+ expect(component.indexOf('const ds_0_params =')).toBeLessThan(
304
+ component.indexOf('if (ds_0_firstPage === undefined)')
305
+ )
306
+ })
307
+
308
+ it('keeps fetching page 1 in the browser under a sort the visitor picks', async () => {
309
+ const { component } = await runPagePlugin({
310
+ infiniteScroll: true,
311
+ sort: { type: 'expr', content: 'sortBy.split("-")[0]' },
312
+ sortDirection: { type: 'expr', content: 'sortBy.split("-")[1]' },
313
+ })
314
+
315
+ // The build-time fetch has no sort, so its rows are not this list's page 1.
316
+ expect(component).not.toContain('initialData')
317
+ expect(component).not.toContain('ds_0_firstPage')
318
+ })
319
+ })
320
+
321
+ /**
322
+ * The accumulator, executed: `fetchData` as the page emits it, answered in
323
+ * the orders a browser can produce. A sort (filter, search) change made on
324
+ * page 2 fires page 2 under the new query first, then page 1 once the page is
325
+ * reset — and `DataProvider` renders whichever answer lands last.
326
+ */
327
+ describe('the appending list, run', () => {
328
+ const PER_PAGE = 2
329
+
330
+ interface Deferred {
331
+ params: { page: number; sorts: string }
332
+ resolve: (rows: string[]) => void
333
+ result: Promise<string[]>
334
+ }
335
+
336
+ function bootList() {
337
+ const vars = getInfiniteScrollVars(0)
338
+ const mark = generator(types.program([buildAccumulatingRequestMark(vars)])).code
339
+ const handler = generator(buildAccumulatingResponseHandler(vars, PER_PAGE)).code
340
+ const accumRef = { current: { sig: '', pages: {}, latest: null } }
341
+ const hasMore: boolean[] = []
342
+ const fetchData = new Function(
343
+ 'ds_0_accumRef',
344
+ 'setDs_0_hasMoreFlag',
345
+ 'answer',
346
+ `return function fetchData(params) { ${mark}; return answer(params).then(${handler}) }`
347
+ )(
348
+ accumRef,
349
+ (value: boolean) => hasMore.push(value),
350
+ (params: Deferred['params']) => {
351
+ let resolve: (rows: string[]) => void = () => undefined
352
+ const answered = new Promise<{ data: string[] }>((done) => {
353
+ resolve = (rows) => done({ data: rows })
354
+ })
355
+ requests.push({ params, resolve, result: undefined as never })
356
+ return answered
357
+ }
358
+ ) as (params: Deferred['params']) => Promise<string[]>
359
+ const requests: Deferred[] = []
360
+ const request = (page: number, sorts: string): Deferred => {
361
+ const result = fetchData({ page, sorts })
362
+ const made = requests[requests.length - 1]
363
+ made.result = result
364
+ return made
365
+ }
366
+ // The page's own first page, handed to the provider instead of fetched: the
367
+ // effect as the page emits it, run the way React runs it after the mount.
368
+ const startFromServerRows = (rows: string[], params: Deferred['params']) =>
369
+ new Function(
370
+ 'useEffect',
371
+ 'ds_0_firstPage',
372
+ 'ds_0_params',
373
+ 'ds_0_accumRef',
374
+ 'setDs_0_hasMoreFlag',
375
+ generator(types.program([buildFirstPageSeedEffect(vars, 'ds_0_params', PER_PAGE)])).code
376
+ )(
377
+ (effect: () => void) => effect(),
378
+ rows,
379
+ params,
380
+ accumRef,
381
+ (value: boolean) => hasMore.push(value)
382
+ )
383
+ const requestedPages = () => requests.map((made) => `${made.params.sorts}${made.params.page}`)
384
+ return { request, hasMore, startFromServerRows, requestedPages }
385
+ }
386
+
387
+ const rowsOf = (sorts: string, page: number) => [`${sorts}${page}a`, `${sorts}${page}b`]
388
+
389
+ it('restarts at page 1 after a sort change on page 2, and loads page 2 on the next click', async () => {
390
+ const list = bootList()
391
+ const first = list.request(1, 'A')
392
+ first.resolve(rowsOf('A', 1))
393
+ const second = list.request(2, 'A')
394
+ second.resolve(rowsOf('A', 2))
395
+ expect(await second.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2)])
396
+
397
+ const stray = list.request(2, 'B')
398
+ const restart = list.request(1, 'B')
399
+ stray.resolve(rowsOf('B', 2))
400
+ // The stray page changes nothing: the list on show stays until page 1 answers.
401
+ expect(await stray.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2)])
402
+ restart.resolve(rowsOf('B', 1))
403
+ expect(await restart.result).toEqual(rowsOf('B', 1))
404
+
405
+ const next = list.request(2, 'B')
406
+ next.resolve(rowsOf('B', 2))
407
+ expect(await next.result).toEqual([...rowsOf('B', 1), ...rowsOf('B', 2)])
408
+ })
409
+
410
+ it('keeps the restarted list when the stray page answers last', async () => {
411
+ const list = bootList()
412
+ list.request(1, 'A').resolve(rowsOf('A', 1))
413
+ const second = list.request(2, 'A')
414
+ second.resolve(rowsOf('A', 2))
415
+ await second.result
416
+
417
+ const stray = list.request(2, 'B')
418
+ const restart = list.request(1, 'B')
419
+ restart.resolve(rowsOf('B', 1))
420
+ expect(await restart.result).toEqual(rowsOf('B', 1))
421
+ stray.resolve(rowsOf('B', 2))
422
+ expect(await stray.result).toEqual(rowsOf('B', 1))
423
+ })
424
+
425
+ it('never lets a late answer to an earlier query replace the list', async () => {
426
+ const list = bootList()
427
+ const before = list.request(1, 'A')
428
+ const after = list.request(1, 'B')
429
+ after.resolve(rowsOf('B', 1))
430
+ expect(await after.result).toEqual(rowsOf('B', 1))
431
+ before.resolve(rowsOf('A', 1))
432
+ expect(await before.result).toEqual(rowsOf('B', 1))
433
+ })
434
+
435
+ it('appends page 2 to the rows the page painted, without requesting page 1', async () => {
436
+ const list = bootList()
437
+ list.startFromServerRows(rowsOf('A', 1), { page: 1, sorts: 'A' })
438
+ expect(list.hasMore).toEqual([true])
439
+
440
+ const next = list.request(2, 'A')
441
+ next.resolve(rowsOf('A', 2))
442
+ expect(await next.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2)])
443
+ const third = list.request(3, 'A')
444
+ third.resolve(['A3a'])
445
+ expect(await third.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2), 'A3a'])
446
+
447
+ expect(list.requestedPages()).toEqual(['A2', 'A3'])
448
+ expect(list.hasMore).toEqual([true, true, false])
449
+ })
450
+
451
+ it('still starts a fresh list on a filter, sort or search change after it', async () => {
452
+ const list = bootList()
453
+ list.startFromServerRows(rowsOf('A', 1), { page: 1, sorts: 'A' })
454
+
455
+ const restart = list.request(1, 'B')
456
+ restart.resolve(rowsOf('B', 1))
457
+ expect(await restart.result).toEqual(rowsOf('B', 1))
458
+ const next = list.request(2, 'B')
459
+ next.resolve(rowsOf('B', 2))
460
+ expect(await next.result).toEqual([...rowsOf('B', 1), ...rowsOf('B', 2)])
461
+ })
462
+
463
+ it('ends the list when the page painted a short first page', () => {
464
+ const list = bootList()
465
+ list.startFromServerRows(['A1a'], { page: 1, sorts: 'A' })
466
+ expect(list.hasMore).toEqual([false])
467
+ })
468
+
469
+ it('keeps the painted rows when an earlier query answers after them', async () => {
470
+ // A search cleared while its answer is still on the way: the provider
471
+ // remounts on the page's rows, then the search's page 1 lands.
472
+ const list = bootList()
473
+ const stray = list.request(1, 'B')
474
+ list.startFromServerRows(rowsOf('A', 1), { page: 1, sorts: 'A' })
475
+ stray.resolve(rowsOf('B', 1))
476
+ expect(await stray.result).toEqual(rowsOf('A', 1))
477
+
478
+ const next = list.request(2, 'A')
479
+ next.resolve(rowsOf('A', 2))
480
+ expect(await next.result).toEqual([...rowsOf('A', 1), ...rowsOf('A', 2)])
481
+ })
482
+
483
+ it('ends the list on a short page of the request it stands on only', async () => {
484
+ const list = bootList()
485
+ const stray = list.request(2, 'A')
486
+ const page1 = list.request(1, 'A')
487
+ stray.resolve(['A2a'])
488
+ await stray.result
489
+ expect(list.hasMore).toEqual([])
490
+ page1.resolve(rowsOf('A', 1))
491
+ await page1.result
492
+ const page2 = list.request(2, 'A')
493
+ page2.resolve(['A2a'])
494
+ expect(await page2.result).toEqual([...rowsOf('A', 1), 'A2a'])
495
+ expect(list.hasMore).toEqual([true, false])
496
+ })
497
+ })
@@ -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
+ })