@teleporthq/teleport-plugin-next-data-source 0.44.0 → 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 (94) hide show
  1. package/__tests__/_helpers/first-page-runtime.ts +184 -0
  2. package/__tests__/_helpers/pagination-widget-fixture.ts +67 -1
  3. package/__tests__/browser-row-policy.test.ts +202 -2
  4. package/__tests__/data-source-request-locale.test.ts +4 -3
  5. package/__tests__/fetchers.test.ts +6 -2
  6. package/__tests__/infinite-scroll.test.ts +210 -5
  7. package/__tests__/page-data-visitor-read.test.ts +196 -0
  8. package/__tests__/plain-list-sort.test.ts +1 -1
  9. package/__tests__/plain-list-starting-sort.test.ts +323 -0
  10. package/__tests__/refetch-loading-state.test.ts +158 -2
  11. package/__tests__/starting-sort-first-page.test.ts +285 -0
  12. package/dist/cjs/cache/ast.d.ts +2 -0
  13. package/dist/cjs/cache/ast.d.ts.map +1 -1
  14. package/dist/cjs/cache/ast.js +5 -2
  15. package/dist/cjs/cache/ast.js.map +1 -1
  16. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  17. package/dist/cjs/data-source-fetchers.js +10 -2
  18. package/dist/cjs/data-source-fetchers.js.map +1 -1
  19. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/teleport.js +1 -1
  21. package/dist/cjs/fetchers/teleport.js.map +1 -1
  22. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/utils/browser-row-policy.js +5 -1
  24. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  25. package/dist/cjs/index.d.ts.map +1 -1
  26. package/dist/cjs/index.js +14 -2
  27. package/dist/cjs/index.js.map +1 -1
  28. package/dist/cjs/infinite-scroll.d.ts +64 -2
  29. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  30. package/dist/cjs/infinite-scroll.js +102 -3
  31. package/dist/cjs/infinite-scroll.js.map +1 -1
  32. package/dist/cjs/loading-state.d.ts +23 -2
  33. package/dist/cjs/loading-state.d.ts.map +1 -1
  34. package/dist/cjs/loading-state.js +50 -15
  35. package/dist/cjs/loading-state.js.map +1 -1
  36. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  37. package/dist/cjs/pagination-plugin.js +200 -64
  38. package/dist/cjs/pagination-plugin.js.map +1 -1
  39. package/dist/cjs/sort-utils.d.ts +53 -1
  40. package/dist/cjs/sort-utils.d.ts.map +1 -1
  41. package/dist/cjs/sort-utils.js +105 -13
  42. package/dist/cjs/sort-utils.js.map +1 -1
  43. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  44. package/dist/cjs/utils.d.ts +3 -2
  45. package/dist/cjs/utils.d.ts.map +1 -1
  46. package/dist/cjs/utils.js +48 -18
  47. package/dist/cjs/utils.js.map +1 -1
  48. package/dist/esm/cache/ast.d.ts +2 -0
  49. package/dist/esm/cache/ast.d.ts.map +1 -1
  50. package/dist/esm/cache/ast.js +3 -1
  51. package/dist/esm/cache/ast.js.map +1 -1
  52. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  53. package/dist/esm/data-source-fetchers.js +10 -2
  54. package/dist/esm/data-source-fetchers.js.map +1 -1
  55. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  56. package/dist/esm/fetchers/teleport.js +1 -1
  57. package/dist/esm/fetchers/teleport.js.map +1 -1
  58. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  59. package/dist/esm/fetchers/utils/browser-row-policy.js +6 -2
  60. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  61. package/dist/esm/index.d.ts.map +1 -1
  62. package/dist/esm/index.js +14 -2
  63. package/dist/esm/index.js.map +1 -1
  64. package/dist/esm/infinite-scroll.d.ts +64 -2
  65. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  66. package/dist/esm/infinite-scroll.js +97 -2
  67. package/dist/esm/infinite-scroll.js.map +1 -1
  68. package/dist/esm/loading-state.d.ts +23 -2
  69. package/dist/esm/loading-state.d.ts.map +1 -1
  70. package/dist/esm/loading-state.js +50 -15
  71. package/dist/esm/loading-state.js.map +1 -1
  72. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  73. package/dist/esm/pagination-plugin.js +204 -68
  74. package/dist/esm/pagination-plugin.js.map +1 -1
  75. package/dist/esm/sort-utils.d.ts +53 -1
  76. package/dist/esm/sort-utils.d.ts.map +1 -1
  77. package/dist/esm/sort-utils.js +100 -12
  78. package/dist/esm/sort-utils.js.map +1 -1
  79. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  80. package/dist/esm/utils.d.ts +3 -2
  81. package/dist/esm/utils.d.ts.map +1 -1
  82. package/dist/esm/utils.js +49 -19
  83. package/dist/esm/utils.js.map +1 -1
  84. package/package.json +4 -4
  85. package/src/cache/ast.ts +4 -1
  86. package/src/data-source-fetchers.ts +33 -14
  87. package/src/fetchers/teleport.ts +8 -2
  88. package/src/fetchers/utils/browser-row-policy.ts +79 -5
  89. package/src/index.ts +8 -2
  90. package/src/infinite-scroll.ts +182 -4
  91. package/src/loading-state.ts +85 -13
  92. package/src/pagination-plugin.ts +369 -83
  93. package/src/sort-utils.ts +164 -21
  94. package/src/utils.ts +55 -18
@@ -0,0 +1,323 @@
1
+ /* tslint:disable:function-constructor no-any */
2
+ import * as types from '@babel/types'
3
+ import generator from '@babel/generator'
4
+ import {
5
+ ChunkDefinition,
6
+ ChunkType,
7
+ ComponentStructure,
8
+ FileType,
9
+ UIDLStateDefinition,
10
+ } from '@teleporthq/teleport-types'
11
+ import { createNextPagesDataSourcePlugin } from '../src/index'
12
+ import {
13
+ bootProductsRoute,
14
+ browserParams,
15
+ buildTimeArguments,
16
+ orderBy,
17
+ rowWindow,
18
+ } from './_helpers/first-page-runtime'
19
+
20
+ /**
21
+ * A PLAIN list — no paging, no search — sorted by the dropdown a visitor picks
22
+ * from (`sortBy.split("-")[0]`, a page state starting at "name-asc"). The
23
+ * page's getStaticProps fetched its rows unsorted and the list dropped them, so
24
+ * the server HTML carried its loading slot and the browser fetched the rows on
25
+ * load. The build now fetches them by the sort a visitor starts with, the list
26
+ * shows them while the sort is still there, and any other sort is fetched fresh
27
+ * — the rule the paged lists follow (starting-sort-first-page.test.ts).
28
+ */
29
+
30
+ const DS_ID = 'ds1'
31
+ const TABLE = 'teleport_products'
32
+
33
+ const STORE_SORT = {
34
+ sort: { type: 'expr', content: `sortBy.split("-")[0] || ''` },
35
+ sortDirection: { type: 'expr', content: `sortBy.split("-")[1] || ''` },
36
+ }
37
+
38
+ // The sort dropdown's state, as the store template declares it.
39
+ const SORT_DROPDOWN: Record<string, UIDLStateDefinition> = {
40
+ sortBy: { type: 'string', defaultValue: 'name-asc', urlSearchParamBinding: { key: 'sortBy' } },
41
+ }
42
+
43
+ const STARTING_SORTS = `(sortBy => JSON.stringify([{ field: sortBy.split("-")?.[0] || '', order: sortBy.split("-")?.[1] || '' }]))("name-asc")`
44
+
45
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
46
+
47
+ const jsx = (name: string, attrs: types.JSXAttribute[], children: types.JSXElement[] = []) =>
48
+ types.jsxElement(
49
+ types.jsxOpeningElement(types.jsxIdentifier(name), attrs, children.length === 0),
50
+ children.length === 0 ? null : types.jsxClosingElement(types.jsxIdentifier(name)),
51
+ children,
52
+ children.length === 0
53
+ )
54
+
55
+ // The provider the component generator emits for a list of six products.
56
+ const makeProvider = (): types.JSXElement =>
57
+ jsx('DataProvider', [
58
+ types.jsxAttribute(
59
+ types.jsxIdentifier('name'),
60
+ types.jsxExpressionContainer(types.stringLiteral('items'))
61
+ ),
62
+ types.jsxAttribute(
63
+ types.jsxIdentifier('resourceDefinition'),
64
+ types.jsxExpressionContainer(
65
+ types.objectExpression([
66
+ types.objectProperty(types.stringLiteral('dataSourceId'), types.stringLiteral(DS_ID)),
67
+ types.objectProperty(types.stringLiteral('tableName'), types.stringLiteral(TABLE)),
68
+ types.objectProperty(
69
+ types.stringLiteral('dataSourceType'),
70
+ types.stringLiteral('teleport')
71
+ ),
72
+ ])
73
+ )
74
+ ),
75
+ types.jsxAttribute(
76
+ types.jsxIdentifier('params'),
77
+ types.jsxExpressionContainer(
78
+ types.objectExpression([
79
+ types.objectProperty(types.stringLiteral('limit'), types.numericLiteral(6)),
80
+ ])
81
+ )
82
+ ),
83
+ types.jsxAttribute(
84
+ types.jsxIdentifier('renderSuccess'),
85
+ types.jsxExpressionContainer(
86
+ types.arrowFunctionExpression(
87
+ [types.identifier('items')],
88
+ jsx('Repeater', [
89
+ types.jsxAttribute(
90
+ types.jsxIdentifier('items'),
91
+ types.jsxExpressionContainer(types.identifier('items'))
92
+ ),
93
+ types.jsxAttribute(
94
+ types.jsxIdentifier('renderItem'),
95
+ types.jsxExpressionContainer(
96
+ types.arrowFunctionExpression([types.identifier('product')], jsx('div', []))
97
+ )
98
+ ),
99
+ ])
100
+ )
101
+ )
102
+ ),
103
+ ])
104
+
105
+ interface ListOptions {
106
+ sorted?: boolean
107
+ filters?: unknown[]
108
+ }
109
+
110
+ const makeListNode = ({ sorted = true, filters }: ListOptions = {}): any => ({
111
+ type: 'data-source-list',
112
+ content: {
113
+ renderPropIdentifier: 'items',
114
+ resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
115
+ resource: {
116
+ id: 'TQ_products',
117
+ params: {
118
+ limit: { type: 'static', content: 6 },
119
+ ...(filters ? { filters: { type: 'static', content: filters } } : {}),
120
+ },
121
+ },
122
+ nodes: {
123
+ success: {
124
+ type: 'cms-list-repeater',
125
+ content: {
126
+ renderPropIdentifier: 'product',
127
+ paginated: false,
128
+ searchEnabled: false,
129
+ ...(sorted ? STORE_SORT : {}),
130
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ })
136
+
137
+ /** A products page drawing the given plain lists, run through the page's data-source plugin. */
138
+ const runPage = async (
139
+ lists: ListOptions[],
140
+ // `null`: the page declares no state, so the build cannot know the sort.
141
+ stateDefinitions: Record<string, UIDLStateDefinition> | null = SORT_DROPDOWN
142
+ ) => {
143
+ const providers = lists.map(makeProvider)
144
+ const chunk: ChunkDefinition = {
145
+ name: 'jsx-component',
146
+ type: ChunkType.AST,
147
+ fileType: FileType.JS,
148
+ linkAfter: [],
149
+ content: types.variableDeclaration('const', [
150
+ types.variableDeclarator(
151
+ types.identifier('ProductsList'),
152
+ types.arrowFunctionExpression(
153
+ [types.identifier('props')],
154
+ types.blockStatement([types.returnStatement(jsx('div', [], providers))])
155
+ )
156
+ ),
157
+ ]),
158
+ meta: { nodesLookup: Object.fromEntries(providers.map((p, i) => [`provider${i}`, p])) },
159
+ }
160
+ const structure: ComponentStructure = {
161
+ uidl: {
162
+ name: 'ProductsList',
163
+ node: {
164
+ type: 'element',
165
+ content: { elementType: 'container', children: lists.map(makeListNode) },
166
+ },
167
+ ...(stateDefinitions ? { stateDefinitions } : {}),
168
+ outputOptions: { folderPath: [], fileName: 'products' },
169
+ },
170
+ chunks: [chunk],
171
+ dependencies: {},
172
+ options: {
173
+ dataSources: {
174
+ [DS_ID]: {
175
+ id: DS_ID,
176
+ name: 'TeleportHQ database',
177
+ type: 'teleport',
178
+ config: { selectedTables: { [TABLE]: { columns: [{ name: 'id' }, { name: 'name' }] } } },
179
+ },
180
+ },
181
+ extractedResources: {},
182
+ },
183
+ } as never
184
+ await createNextPagesDataSourcePlugin()(structure)
185
+ const staticPropsChunk = structure.chunks.find((c) => c.name === 'getStaticProps')
186
+ return {
187
+ component: collapse(generator(chunk.content as types.Node).code),
188
+ staticProps: staticPropsChunk
189
+ ? collapse(generator(staticPropsChunk.content as types.Node).code)
190
+ : '',
191
+ chunk,
192
+ staticPropsChunk,
193
+ providers,
194
+ }
195
+ }
196
+
197
+ // The provider's `initialData` as the browser evaluates it for the given state.
198
+ const initialDataOf = (provider: types.JSXElement, state: Record<string, unknown>): unknown => {
199
+ const attr = provider.openingElement.attributes.find(
200
+ (a) => a.type === 'JSXAttribute' && a.name.name === 'initialData'
201
+ ) as types.JSXAttribute | undefined
202
+ if (!attr) {
203
+ return undefined
204
+ }
205
+ const expression = (attr.value as types.JSXExpressionContainer).expression as types.Expression
206
+ const names = Object.keys(state)
207
+ return new Function(...names, `return (${generator(expression).code})`)(
208
+ ...names.map((name) => state[name])
209
+ )
210
+ }
211
+
212
+ describe('a plain list sorted by a dropdown, on a page', () => {
213
+ it('fetches its rows at build time by the sort a visitor starts with, as a visitor', async () => {
214
+ const { staticProps } = await runPage([{}])
215
+
216
+ // The browser's own sort expression, with the dropdown at its default.
217
+ expect(staticProps).toContain(
218
+ `teleportTeleportProductsDs1.fetchData({ "limit": 6, "sorts": ${STARTING_SORTS} }, { as: "visitor" })`
219
+ )
220
+ })
221
+
222
+ it('shows those rows while the sort is where a visitor starts', async () => {
223
+ const { component } = await runPage([{}])
224
+
225
+ expect(component).toContain(
226
+ 'initialData={sortBy === "name-asc" ? props.items_products : undefined}'
227
+ )
228
+ // Any other sort is another query: the params follow the dropdown.
229
+ expect(component).toMatch(
230
+ /sorts: JSON\.stringify\(\[\{ field: sortBy\.split\("-"\).*\}\), \[sortBy\]\)\}/
231
+ )
232
+ })
233
+
234
+ it('keeps fetching in the browser when the build cannot know where the sort starts', async () => {
235
+ const { component, staticProps } = await runPage([{}], null)
236
+
237
+ expect(component).not.toContain('initialData')
238
+ expect(staticProps).not.toContain('"sorts"')
239
+ })
240
+
241
+ it('fetches an unsorted list beside it with the same params on its own', async () => {
242
+ const { component, staticProps } = await runPage([{ sorted: false }, {}])
243
+
244
+ expect(staticProps).toContain(
245
+ 'const [items_products, items_products_2] = await Promise.all([teleportTeleportProductsDs1.fetchData({ "limit": 6 }, { as: "visitor" })'
246
+ )
247
+ expect(staticProps).toContain(
248
+ `teleportTeleportProductsDs1.fetchData({ "limit": 6, "sorts": ${STARTING_SORTS} }, { as: "visitor" })`
249
+ )
250
+ expect(component).toContain('initialData={props.items_products}')
251
+ expect(component).toContain(
252
+ 'initialData={sortBy === "name-asc" ? props.items_products_2 : undefined}'
253
+ )
254
+ })
255
+
256
+ it('is never seeded while a visitor filters it', async () => {
257
+ const byCategory = {
258
+ type: 'condition',
259
+ source: 'category',
260
+ destination: { type: 'dynamic', content: { referenceType: 'state', id: 'selectedCategory' } },
261
+ operand: '=',
262
+ }
263
+ const { component, staticProps } = await runPage([{ filters: [byCategory] }], {
264
+ ...SORT_DROPDOWN,
265
+ selectedCategory: { type: 'string', defaultValue: '' },
266
+ })
267
+
268
+ expect(component).not.toContain('initialData')
269
+ expect(staticProps).not.toContain('fetchData')
270
+ })
271
+ })
272
+
273
+ /**
274
+ * Executed: the rows the build fetches and the ones the browser asks for must
275
+ * be one ordering of one row set, and the build's stand in only for the
276
+ * starting sort.
277
+ */
278
+ describe('a plain list sorted by a dropdown, run', () => {
279
+ it('sends the build and the browser one sort string while the dropdown is untouched', async () => {
280
+ const { staticPropsChunk, chunk } = await runPage([{}])
281
+ const [atBuild, readAs] = buildTimeArguments(staticPropsChunk)
282
+
283
+ expect(readAs).toEqual({ as: 'visitor' })
284
+ expect(atBuild.sorts).toBe('[{"field":"name","order":"asc"}]')
285
+ expect(browserParams(chunk, { sortBy: 'name-asc' })).toEqual({
286
+ limit: '6',
287
+ sorts: atBuild.sorts,
288
+ })
289
+ expect(browserParams(chunk, { sortBy: 'price-desc' }).sorts).toBe(
290
+ '[{"field":"price","order":"desc"}]'
291
+ )
292
+ })
293
+
294
+ it('orders the rows at build time exactly as the browser orders them', async () => {
295
+ const { staticPropsChunk, chunk } = await runPage([{}])
296
+ const route = bootProductsRoute()
297
+
298
+ const atBuild = await route.atBuild(buildTimeArguments(staticPropsChunk))
299
+ const inBrowser = await route.inBrowser(browserParams(chunk, { sortBy: 'name-asc' }))
300
+
301
+ // The same columns, rows without a value last, the row's id breaking ties,
302
+ // over the products a visitor is served.
303
+ expect(orderBy(atBuild)).toBe(' ORDER BY name ASC NULLS LAST, "id" ASC LIMIT')
304
+ expect(rowWindow(atBuild)).toBe(' LIMIT 6')
305
+ expect(atBuild).toContain("WHERE (LOWER(TRIM(status)) = 'active')")
306
+ expect(inBrowser).toBe(atBuild)
307
+ })
308
+
309
+ it("shows the build's rows for the starting sort only; any other is fetched fresh", async () => {
310
+ const { providers, staticPropsChunk, chunk } = await runPage([{ sorted: false }, {}])
311
+ const props = { items_products: ['unsorted'], items_products_2: ['by name'] }
312
+
313
+ expect(initialDataOf(providers[1], { sortBy: 'name-asc', props })).toEqual(['by name'])
314
+ // Another pick: no seed, and params the provider has not fetched yet.
315
+ expect(initialDataOf(providers[1], { sortBy: 'price-desc', props })).toBeUndefined()
316
+ expect(browserParams(chunk, { sortBy: 'price-desc' }, 1).sorts).not.toBe(
317
+ buildTimeArguments(staticPropsChunk, 'items_products_2')[0].sorts
318
+ )
319
+ // The unsorted list beside it keeps its own rows, whatever the dropdown says.
320
+ expect(initialDataOf(providers[0], { sortBy: 'price-desc', props })).toEqual(['unsorted'])
321
+ expect(buildTimeArguments(staticPropsChunk, 'items_products')[0]).toEqual({ limit: 6 })
322
+ })
323
+ })
@@ -98,7 +98,8 @@ const makeComponentChunk = (dataProvider: types.JSXElement): ChunkDefinition =>
98
98
  // A `data-source-list > cms-list-repeater` UIDL. `category` decides which
99
99
  // DataProvider updater runs, so every one of them is covered below.
100
100
  const makeUidlNode = (
101
- category: 'paginated+search' | 'paginated-only' | 'search-only' | 'plain'
101
+ category: 'paginated+search' | 'paginated-only' | 'search-only' | 'plain',
102
+ repeater: Record<string, unknown> = {}
102
103
  // tslint:disable-next-line:no-any
103
104
  ): any => ({
104
105
  type: 'data-source-list',
@@ -120,6 +121,7 @@ const makeUidlNode = (
120
121
  searchEnabled: category === 'paginated+search' || category === 'search-only',
121
122
  searchDebounce: 300,
122
123
  nodes: { list: { type: 'element', content: { elementType: 'div' } } },
124
+ ...repeater,
123
125
  },
124
126
  },
125
127
  },
@@ -129,11 +131,17 @@ const makeUidlNode = (
129
131
  const runPlugin = async (options: {
130
132
  category?: 'paginated+search' | 'paginated-only' | 'search-only' | 'plain'
131
133
  withLoadingSlot?: boolean
134
+ repeater?: Record<string, unknown>
135
+ onChunk?: (chunk: ChunkDefinition) => void
132
136
  }): Promise<string> => {
133
137
  const dataProvider = makeDataProviderJSX({ withLoadingSlot: options.withLoadingSlot !== false })
134
138
  const chunk = makeComponentChunk(dataProvider)
139
+ options.onChunk?.(chunk)
135
140
  const structure: ComponentStructure = {
136
- uidl: { name: 'ProductsList', node: makeUidlNode(options.category || 'paginated+search') },
141
+ uidl: {
142
+ name: 'ProductsList',
143
+ node: makeUidlNode(options.category || 'paginated+search', options.repeater),
144
+ },
137
145
  chunks: [chunk],
138
146
  dependencies: {},
139
147
  options: { dataSources: {}, extractedResources: {} },
@@ -285,3 +293,151 @@ describe('pagination plugin — refetch loading state', () => {
285
293
  expect(code).toContain('persistDataDuringLoading={true}')
286
294
  })
287
295
  })
296
+
297
+ /**
298
+ * An appending list (infinite scroll, Load more) loads its next page BELOW the
299
+ * rows on show. Raising the flag for that request too swapped every row for the
300
+ * loading slot until the page arrived: the list collapsed under the visitor's
301
+ * scroll on each append. Only a list loading afresh shows its loading slot.
302
+ */
303
+ describe('pagination plugin — refetch loading state of an appending list', () => {
304
+ it.each([
305
+ ['infinite scroll', { infiniteScroll: true }],
306
+ ['Load more', { infiniteScroll: true, infiniteScrollLoadMore: true }],
307
+ ])(
308
+ 'keeps the rows on screen while the next page of a list with %s loads',
309
+ async (_, repeater) => {
310
+ const code = (await runPlugin({ category: 'paginated-only', repeater })).replace(/\s+/g, ' ')
311
+
312
+ expect(code).toContain('const [ds_0_isAppending, setDs_0_isAppending] = useState(false)')
313
+ // Recorded with the request, from the request the list now stands on.
314
+ expect(code).toContain(
315
+ 'setDs_0_isAppending(ds_0_accumRef.current.latest.page > 1 && ds_0_accumRef.current.latest.sig === ds_0_accumRef.current.sig); setDs_0_isFetching(true);'
316
+ )
317
+ expect(code.indexOf('ds_0_accumRef.current.latest = {')).toBeLessThan(
318
+ code.indexOf('setDs_0_isAppending(ds_0_accumRef')
319
+ )
320
+ expect(code).toContain('persistDataDuringLoading={!ds_0_isFetching || ds_0_isAppending}')
321
+ }
322
+ )
323
+
324
+ it('leaves a list that pages unchanged: a page change loads it afresh', async () => {
325
+ const code = await runPlugin({ category: 'paginated-only' })
326
+
327
+ expect(code).toContain('persistDataDuringLoading={!ds_0_isFetching}')
328
+ expect(code).not.toContain('isAppending')
329
+ })
330
+ })
331
+
332
+ /**
333
+ * Executed: the `fetchData` and `persistDataDuringLoading` the page emits, under
334
+ * a model of what `DataProvider` (@teleporthq/react-components) paints:
335
+ *
336
+ * on a params change: status = "loading"; if (!persistDataDuringLoading) data = undefined
337
+ * while loading: persistDataDuringLoading && data ? renderSuccess(data) : renderLoading()
338
+ * once answered: data = <what fetchData resolved with>
339
+ */
340
+ describe('an appending list, run', () => {
341
+ const PER_PAGE = 20
342
+ // tslint:disable:no-any
343
+
344
+ const findAttribute = (root: types.Node, name: string): types.Expression => {
345
+ let found: types.Expression | undefined
346
+ types.traverseFast(root, (node) => {
347
+ if (
348
+ !found &&
349
+ node.type === 'JSXAttribute' &&
350
+ node.name.name === name &&
351
+ node.value?.type === 'JSXExpressionContainer'
352
+ ) {
353
+ found = node.value.expression as types.Expression
354
+ }
355
+ })
356
+ return found!
357
+ }
358
+
359
+ const bootList = async () => {
360
+ let chunk: ChunkDefinition | undefined
361
+ await runPlugin({
362
+ category: 'paginated-only',
363
+ repeater: { infiniteScroll: true, infiniteScrollLoadMore: true },
364
+ onChunk: (made) => (chunk = made),
365
+ })
366
+ const content = chunk!.content as types.Node
367
+ const memoized = findAttribute(content, 'fetchData') as types.CallExpression
368
+ const persistCode = generator(findAttribute(content, 'persistDataDuringLoading')).code
369
+
370
+ const state = { isFetching: false, isAppending: false }
371
+ const scope = {
372
+ ds_0_accumRef: { current: { sig: '', pages: {}, latest: null } },
373
+ ds_0_fetchSeq: { current: 0 },
374
+ ds_0_fetchesInFlight: { current: 0 },
375
+ ds_0_latestData: { current: undefined },
376
+ setDs_0_hasMoreFlag: () => undefined,
377
+ setDs_0_isFetching: (value: boolean) => (state.isFetching = value),
378
+ setDs_0_isAppending: (value: boolean) => (state.isAppending = value),
379
+ }
380
+ const answers: Array<() => void> = []
381
+ const fakeFetch = (url: string) => {
382
+ const query = new URL(url, 'http://shop.test').searchParams
383
+ const page = Number(query.get('page'))
384
+ const rows = Array.from({ length: PER_PAGE }, (_, i) => `${query.get('sorts')}${page}-${i}`)
385
+ return new Promise((resolve) => answers.push(() => resolve({ json: () => ({ data: rows }) })))
386
+ }
387
+ const fetchData = new Function(
388
+ 'fetch',
389
+ ...Object.keys(scope),
390
+ `return (${generator(memoized.arguments[0]).code})`
391
+ )(fakeFetch, ...Object.values(scope))
392
+ const persistDataDuringLoading = (): boolean =>
393
+ new Function('ds_0_isFetching', 'ds_0_isAppending', `return (${persistCode})`)(
394
+ state.isFetching,
395
+ state.isAppending
396
+ )
397
+
398
+ // The provider: a component's list, so nothing on show until page 1.
399
+ let data: string[] | undefined
400
+ let status = 'idle'
401
+ let persistAtLastPaint = persistDataDuringLoading()
402
+ const paint = () => {
403
+ persistAtLastPaint = persistDataDuringLoading()
404
+ if (status === 'success') {
405
+ return `${data!.length} rows`
406
+ }
407
+ return persistAtLastPaint && data ? `${data.length} rows` : 'loading slot'
408
+ }
409
+ const load = async (page: number, sorts: string) => {
410
+ status = 'loading'
411
+ if (!persistAtLastPaint) {
412
+ data = undefined
413
+ }
414
+ const answered = fetchData({ page, perPage: PER_PAGE, sorts })
415
+ const whileLoading = paint()
416
+ answers.shift()!()
417
+ data = await answered
418
+ status = 'success'
419
+ return { whileLoading, after: paint() }
420
+ }
421
+ return { load }
422
+ }
423
+
424
+ it('paints its loading slot for its first page, then keeps the rows through every append', async () => {
425
+ const list = await bootList()
426
+
427
+ expect(await list.load(1, 'A')).toEqual({ whileLoading: 'loading slot', after: '20 rows' })
428
+ expect(await list.load(2, 'A')).toEqual({ whileLoading: '20 rows', after: '40 rows' })
429
+ expect(await list.load(3, 'A')).toEqual({ whileLoading: '40 rows', after: '60 rows' })
430
+ })
431
+
432
+ it('paints the loading slot again for a fresh list: a sort, filter or search change', async () => {
433
+ const list = await bootList()
434
+ await list.load(1, 'A')
435
+ await list.load(2, 'A')
436
+
437
+ // The page the change fires before the list is reset to page 1 is another
438
+ // query's, and no append either.
439
+ expect((await list.load(2, 'B')).whileLoading).toBe('loading slot')
440
+ expect(await list.load(1, 'B')).toEqual({ whileLoading: 'loading slot', after: '20 rows' })
441
+ expect(await list.load(2, 'B')).toEqual({ whileLoading: '20 rows', after: '40 rows' })
442
+ })
443
+ })