@teleporthq/teleport-plugin-next-data-source 0.43.75 → 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 (136) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +87 -4
  2. package/__tests__/booking-item-transform.test.ts +120 -0
  3. package/__tests__/browser-row-policy.test.ts +8 -2
  4. package/__tests__/fetchers.test.ts +1 -1
  5. package/__tests__/fixed-list-sort.test.ts +1 -1
  6. package/__tests__/infinite-scroll.test.ts +154 -5
  7. package/__tests__/list-sort-order.test.ts +61 -0
  8. package/__tests__/null-filter-where.test.ts +132 -0
  9. package/__tests__/pg-date-parser.test.ts +73 -0
  10. package/__tests__/plain-list-sort.test.ts +1 -1
  11. package/__tests__/product-price-sort.ts +11 -6
  12. package/__tests__/range-control.test.ts +159 -0
  13. package/__tests__/search-input-marker.test.ts +86 -0
  14. package/__tests__/search-url-sync.test.ts +34 -8
  15. package/__tests__/table-read-route-protected-tables.test.ts +33 -3
  16. package/dist/cjs/count-effect.d.ts +7 -1
  17. package/dist/cjs/count-effect.d.ts.map +1 -1
  18. package/dist/cjs/count-effect.js +15 -5
  19. package/dist/cjs/count-effect.js.map +1 -1
  20. package/dist/cjs/fetchers/pg-client-import.d.ts +12 -0
  21. package/dist/cjs/fetchers/pg-client-import.d.ts.map +1 -0
  22. package/dist/cjs/fetchers/pg-client-import.js +15 -0
  23. package/dist/cjs/fetchers/pg-client-import.js.map +1 -0
  24. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  25. package/dist/cjs/fetchers/postgresql.js +3 -1
  26. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  27. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  28. package/dist/cjs/fetchers/raw-query.js +2 -1
  29. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  30. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  31. package/dist/cjs/fetchers/teleport.js +4 -2
  32. package/dist/cjs/fetchers/teleport.js.map +1 -1
  33. package/dist/cjs/infinite-scroll.d.ts +25 -2
  34. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  35. package/dist/cjs/infinite-scroll.js +144 -33
  36. package/dist/cjs/infinite-scroll.js.map +1 -1
  37. package/dist/cjs/list-sort-order.d.ts +14 -0
  38. package/dist/cjs/list-sort-order.d.ts.map +1 -0
  39. package/dist/cjs/list-sort-order.js +36 -0
  40. package/dist/cjs/list-sort-order.js.map +1 -0
  41. package/dist/cjs/pagination-controls.d.ts +22 -3
  42. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  43. package/dist/cjs/pagination-controls.js +29 -3
  44. package/dist/cjs/pagination-controls.js.map +1 -1
  45. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  46. package/dist/cjs/pagination-plugin.js +137 -101
  47. package/dist/cjs/pagination-plugin.js.map +1 -1
  48. package/dist/cjs/range-control.d.ts +45 -0
  49. package/dist/cjs/range-control.d.ts.map +1 -0
  50. package/dist/cjs/range-control.js +165 -0
  51. package/dist/cjs/range-control.js.map +1 -0
  52. package/dist/cjs/row-order.d.ts +3 -2
  53. package/dist/cjs/row-order.d.ts.map +1 -1
  54. package/dist/cjs/row-order.js +5 -4
  55. package/dist/cjs/row-order.js.map +1 -1
  56. package/dist/cjs/search-input.d.ts +42 -0
  57. package/dist/cjs/search-input.d.ts.map +1 -0
  58. package/dist/cjs/search-input.js +158 -0
  59. package/dist/cjs/search-input.js.map +1 -0
  60. package/dist/cjs/transformations/booking-item.d.ts +24 -0
  61. package/dist/cjs/transformations/booking-item.d.ts.map +1 -0
  62. package/dist/cjs/transformations/booking-item.js +27 -0
  63. package/dist/cjs/transformations/booking-item.js.map +1 -0
  64. package/dist/cjs/transformations/index.d.ts +4 -2
  65. package/dist/cjs/transformations/index.d.ts.map +1 -1
  66. package/dist/cjs/transformations/index.js +14 -5
  67. package/dist/cjs/transformations/index.js.map +1 -1
  68. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  69. package/dist/esm/count-effect.d.ts +7 -1
  70. package/dist/esm/count-effect.d.ts.map +1 -1
  71. package/dist/esm/count-effect.js +15 -5
  72. package/dist/esm/count-effect.js.map +1 -1
  73. package/dist/esm/fetchers/pg-client-import.d.ts +12 -0
  74. package/dist/esm/fetchers/pg-client-import.d.ts.map +1 -0
  75. package/dist/esm/fetchers/pg-client-import.js +12 -0
  76. package/dist/esm/fetchers/pg-client-import.js.map +1 -0
  77. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  78. package/dist/esm/fetchers/postgresql.js +3 -1
  79. package/dist/esm/fetchers/postgresql.js.map +1 -1
  80. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  81. package/dist/esm/fetchers/raw-query.js +2 -1
  82. package/dist/esm/fetchers/raw-query.js.map +1 -1
  83. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  84. package/dist/esm/fetchers/teleport.js +4 -2
  85. package/dist/esm/fetchers/teleport.js.map +1 -1
  86. package/dist/esm/infinite-scroll.d.ts +25 -2
  87. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  88. package/dist/esm/infinite-scroll.js +142 -32
  89. package/dist/esm/infinite-scroll.js.map +1 -1
  90. package/dist/esm/list-sort-order.d.ts +14 -0
  91. package/dist/esm/list-sort-order.d.ts.map +1 -0
  92. package/dist/esm/list-sort-order.js +32 -0
  93. package/dist/esm/list-sort-order.js.map +1 -0
  94. package/dist/esm/pagination-controls.d.ts +22 -3
  95. package/dist/esm/pagination-controls.d.ts.map +1 -1
  96. package/dist/esm/pagination-controls.js +28 -2
  97. package/dist/esm/pagination-controls.js.map +1 -1
  98. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  99. package/dist/esm/pagination-plugin.js +138 -102
  100. package/dist/esm/pagination-plugin.js.map +1 -1
  101. package/dist/esm/range-control.d.ts +45 -0
  102. package/dist/esm/range-control.d.ts.map +1 -0
  103. package/dist/esm/range-control.js +137 -0
  104. package/dist/esm/range-control.js.map +1 -0
  105. package/dist/esm/row-order.d.ts +3 -2
  106. package/dist/esm/row-order.d.ts.map +1 -1
  107. package/dist/esm/row-order.js +5 -4
  108. package/dist/esm/row-order.js.map +1 -1
  109. package/dist/esm/search-input.d.ts +42 -0
  110. package/dist/esm/search-input.d.ts.map +1 -0
  111. package/dist/esm/search-input.js +129 -0
  112. package/dist/esm/search-input.js.map +1 -0
  113. package/dist/esm/transformations/booking-item.d.ts +24 -0
  114. package/dist/esm/transformations/booking-item.d.ts.map +1 -0
  115. package/dist/esm/transformations/booking-item.js +23 -0
  116. package/dist/esm/transformations/booking-item.js.map +1 -0
  117. package/dist/esm/transformations/index.d.ts +4 -2
  118. package/dist/esm/transformations/index.d.ts.map +1 -1
  119. package/dist/esm/transformations/index.js +14 -5
  120. package/dist/esm/transformations/index.js.map +1 -1
  121. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  122. package/package.json +5 -5
  123. package/src/count-effect.ts +31 -8
  124. package/src/fetchers/pg-client-import.ts +16 -0
  125. package/src/fetchers/postgresql.ts +15 -9
  126. package/src/fetchers/raw-query.ts +2 -1
  127. package/src/fetchers/teleport.ts +11 -8
  128. package/src/infinite-scroll.ts +240 -79
  129. package/src/list-sort-order.ts +34 -0
  130. package/src/pagination-controls.ts +26 -3
  131. package/src/pagination-plugin.ts +163 -135
  132. package/src/range-control.ts +207 -0
  133. package/src/row-order.ts +5 -4
  134. package/src/search-input.ts +190 -0
  135. package/src/transformations/booking-item.ts +71 -0
  136. package/src/transformations/index.ts +25 -2
@@ -1,6 +1,11 @@
1
1
  import * as types from '@babel/types'
2
2
  import { ChunkType, FileType, ChunkDefinition } from '@teleporthq/teleport-types'
3
- import { PAGINATION_CONTROL_ATTR } from '../../src/pagination-controls'
3
+ import {
4
+ PAGINATION_CONTROL_ATTR,
5
+ PAGINATION_FOR_ATTR,
6
+ RANGE_TEMPLATE_ATTR,
7
+ } from '../../src/pagination-controls'
8
+ import { SEARCH_FOR_ATTR } from '../../src/search-input'
4
9
 
5
10
  /**
6
11
  * A component shell carrying a `DataProvider` and the pagination controls a
@@ -20,9 +25,36 @@ interface ControlOptions {
20
25
  legacyOnly?: boolean
21
26
  /** Number of DataProvider + widget pairs to emit (order-based zip coverage). */
22
27
  mappers?: number
28
+ /** The element the providers render in: a table's body, a list. */
29
+ rootTag?: string
30
+ /**
31
+ * The `data-tq-range-template` of a `range` control in the widget. Defaults
32
+ * to the "Showing" wording; `null` authors the control with no template.
33
+ */
34
+ rangeTemplate?: string | null
35
+ /**
36
+ * A `range` text OUTSIDE every widget, placed BEFORE the first provider (a
37
+ * toolbar above the list), naming its mapper with `data-tq-pagination-for`.
38
+ */
39
+ standaloneRange?: { forNodeId: string; template?: string }
40
+ /**
41
+ * Search inputs OUTSIDE every widget, placed BEFORE the first provider, in
42
+ * this order: a page's own box naming its mapper with `data-tq-search-for`,
43
+ * or the platform's (`search-input` class) paired by order.
44
+ */
45
+ standaloneSearch?: Array<{ forNodeId?: string; name: string }>
23
46
  }
24
47
 
25
- const control = (marker: string, children: types.JSXElement['children'] = []): types.JSXElement =>
48
+ // ASCII hyphen on purpose: the generator prints string literals through jsesc,
49
+ // which would turn an en dash into `–` and make every assertion unreadable.
50
+ export const RANGE_DRAWN_TEXT = 'Showing 1-8 of 12'
51
+ export const RANGE_TEMPLATE = 'Showing {from}–{to} of {total}'
52
+
53
+ const control = (
54
+ marker: string,
55
+ children: types.JSXElement['children'] = [],
56
+ extraAttrs: Array<[string, string]> = []
57
+ ): types.JSXElement =>
26
58
  types.jsxElement(
27
59
  types.jsxOpeningElement(
28
60
  types.jsxIdentifier('div'),
@@ -35,6 +67,9 @@ const control = (marker: string, children: types.JSXElement['children'] = []): t
35
67
  types.jsxIdentifier(PAGINATION_CONTROL_ATTR),
36
68
  types.stringLiteral(marker)
37
69
  ),
70
+ ...extraAttrs.map(([name, value]) =>
71
+ types.jsxAttribute(types.jsxIdentifier(name), types.stringLiteral(value))
72
+ ),
38
73
  ],
39
74
  false
40
75
  ),
@@ -43,6 +78,40 @@ const control = (marker: string, children: types.JSXElement['children'] = []): t
43
78
  false
44
79
  )
45
80
 
81
+ const rangeControl = (
82
+ template: string | null | undefined,
83
+ forNodeId?: string
84
+ ): types.JSXElement => {
85
+ const attrs: Array<[string, string]> = []
86
+ if (template !== null) {
87
+ attrs.push([RANGE_TEMPLATE_ATTR, template ?? RANGE_TEMPLATE])
88
+ }
89
+ if (forNodeId) {
90
+ attrs.push([PAGINATION_FOR_ATTR, forNodeId])
91
+ }
92
+ return control('range', [types.jsxText(RANGE_DRAWN_TEXT)], attrs)
93
+ }
94
+
95
+ const searchInput = (name: string, forNodeId?: string): types.JSXElement =>
96
+ types.jsxElement(
97
+ types.jsxOpeningElement(
98
+ types.jsxIdentifier('input'),
99
+ [
100
+ types.jsxAttribute(types.jsxIdentifier('name'), types.stringLiteral(name)),
101
+ forNodeId
102
+ ? types.jsxAttribute(types.jsxIdentifier(SEARCH_FOR_ATTR), types.stringLiteral(forNodeId))
103
+ : types.jsxAttribute(
104
+ types.jsxIdentifier('className'),
105
+ types.stringLiteral('products-list-search-input')
106
+ ),
107
+ ],
108
+ true
109
+ ),
110
+ null,
111
+ [],
112
+ true
113
+ )
114
+
46
115
  const legacyControl = (direction: 'previous' | 'next'): types.JSXElement =>
47
116
  types.jsxElement(
48
117
  types.jsxOpeningElement(
@@ -72,6 +141,10 @@ const buildWidget = (options: ControlOptions): types.JSXElement => {
72
141
  children.push(legacyControl(marker))
73
142
  continue
74
143
  }
144
+ if (marker === 'range') {
145
+ children.push(rangeControl(options.rangeTemplate))
146
+ continue
147
+ }
75
148
  children.push(control(marker))
76
149
  }
77
150
 
@@ -148,6 +221,12 @@ const buildDataProvider = (name: string, rowIdentifier: string): types.JSXElemen
148
221
 
149
222
  export const makeWidgetComponentChunk = (options: ControlOptions): ChunkDefinition => {
150
223
  const sections: types.JSXElement[] = []
224
+ if (options.standaloneRange) {
225
+ sections.push(rangeControl(options.standaloneRange.template, options.standaloneRange.forNodeId))
226
+ }
227
+ for (const search of options.standaloneSearch ?? []) {
228
+ sections.push(searchInput(search.name, search.forNodeId))
229
+ }
151
230
  const mappers = options.mappers ?? 1
152
231
  for (let i = 0; i < mappers; i += 1) {
153
232
  const identifier = `items${i === 0 ? '' : i}`
@@ -155,9 +234,10 @@ export const makeWidgetComponentChunk = (options: ControlOptions): ChunkDefiniti
155
234
  sections.push(buildWidget(options))
156
235
  }
157
236
 
237
+ const rootTag = options.rootTag ?? 'div'
158
238
  const root = types.jsxElement(
159
- types.jsxOpeningElement(types.jsxIdentifier('div'), [], false),
160
- types.jsxClosingElement(types.jsxIdentifier('div')),
239
+ types.jsxOpeningElement(types.jsxIdentifier(rootTag), [], false),
240
+ types.jsxClosingElement(types.jsxIdentifier(rootTag)),
161
241
  sections,
162
242
  false
163
243
  )
@@ -189,6 +269,8 @@ export interface RepeaterOptions {
189
269
  perPage?: number
190
270
  /** The list's filters, as the inspector authors them (conditions and groups). */
191
271
  filters?: unknown[]
272
+ /** The mapper's editor node id, as the UIDL carries it on the repeater. */
273
+ nodeId?: string
192
274
  }
193
275
 
194
276
  export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'items'): any => ({
@@ -218,6 +300,7 @@ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'ite
218
300
  ...(options.paginationMode ? { paginationMode: options.paginationMode } : {}),
219
301
  ...(options.infiniteScroll ? { infiniteScroll: true } : {}),
220
302
  ...(options.infiniteScrollLoadMore ? { infiniteScrollLoadMore: true } : {}),
303
+ ...(options.nodeId ? { nodeId: options.nodeId } : {}),
221
304
  nodes: { list: { type: 'element', content: { elementType: 'div' } } },
222
305
  },
223
306
  },
@@ -0,0 +1,120 @@
1
+ import { generateBookingItemTransformationCode } from '../src/transformations/booking-item'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+ import {
4
+ detectTransformationType,
5
+ getTransformExpression,
6
+ getTransformWrapperCode,
7
+ } from '../src/transformations'
8
+
9
+ // A booking page binds an item's columns as stored; the transform only puts
10
+ // its texts in the page's language. These evaluate the EMITTED code, the way
11
+ // the runtime does.
12
+
13
+ type Build = (record: unknown, options?: Record<string, unknown>) => Record<string, unknown>
14
+
15
+ const builderFor = (code: string): Build =>
16
+ new Function(
17
+ generateSharedTransformationCode() + '\n' + code + '\nreturn buildBookingItem;'
18
+ )() as Build
19
+
20
+ const buildBookingItem = builderFor(generateBookingItemTransformationCode())
21
+
22
+ const buildWithCategories = builderFor(
23
+ generateBookingItemTransformationCode({
24
+ categories: [
25
+ {
26
+ id: 'rooms',
27
+ name: 'Rooms',
28
+ slug: 'rooms',
29
+ parentId: null,
30
+ order: 0,
31
+ translations: { es: { name: 'Habitaciones' } },
32
+ children: [
33
+ {
34
+ id: 'suites',
35
+ name: 'Suites',
36
+ slug: 'suites',
37
+ parentId: 'rooms',
38
+ order: 0,
39
+ children: [],
40
+ },
41
+ ],
42
+ },
43
+ ],
44
+ })
45
+ )
46
+
47
+ const item = {
48
+ id: 'i1',
49
+ slug: 'double-room',
50
+ name: 'Double room',
51
+ es_name: 'Habitación doble',
52
+ description: '<p>Sea view.</p>',
53
+ es_description: '<p>Vista al mar.</p>',
54
+ location: 'Main street',
55
+ es_location: '',
56
+ price: '120.00',
57
+ booking_unit: 'night',
58
+ }
59
+
60
+ describe('the booking item transform', () => {
61
+ it('is applied to the booking items table only', () => {
62
+ expect(detectTransformationType('teleport_booking_items')).toBe('booking-item')
63
+ expect(detectTransformationType('public.TELEPORT_BOOKING_ITEMS')).toBe('booking-item')
64
+ expect(detectTransformationType('booking_items')).toBeNull()
65
+ expect(getTransformExpression('teleport_booking_items')).toContain('transformRecords')
66
+ expect(getTransformWrapperCode('teleport_booking_items')).toContain('transformBookingItems')
67
+ })
68
+
69
+ it('shows the texts in the page’s language, every column kept as stored', () => {
70
+ const row = buildBookingItem(item, { currentLanguage: 'es', mainLanguage: 'en' })
71
+ expect(row).toMatchObject({
72
+ name: 'Habitación doble',
73
+ description: '<p>Vista al mar.</p>',
74
+ // Not translated yet: the main language's.
75
+ location: 'Main street',
76
+ slug: 'double-room',
77
+ price: '120.00',
78
+ booking_unit: 'night',
79
+ es_name: 'Habitación doble',
80
+ })
81
+ })
82
+
83
+ it('names its category the way the taxonomy names its first one in the page’s language', () => {
84
+ const filed = { ...item, category: 'Rooms', category_ids: '["rooms","suites"]' }
85
+ expect(buildWithCategories(filed, { currentLanguage: 'es', mainLanguage: 'en' }).category).toBe(
86
+ 'Habitaciones'
87
+ )
88
+ expect(buildWithCategories(filed, { currentLanguage: 'en', mainLanguage: 'en' }).category).toBe(
89
+ 'Rooms'
90
+ )
91
+ // No translation: the main name; a nested category resolves too.
92
+ expect(
93
+ buildWithCategories(
94
+ { ...filed, category: 'Suites', category_ids: '["suites"]' },
95
+ { currentLanguage: 'es', mainLanguage: 'en' }
96
+ ).category
97
+ ).toBe('Suites')
98
+ })
99
+
100
+ it('keeps the stored category while none of its ids is a category', () => {
101
+ const legacy = { ...item, category: 'Rooms', category_ids: '["gone"]' }
102
+ expect(
103
+ buildWithCategories(legacy, { currentLanguage: 'es', mainLanguage: 'en' }).category
104
+ ).toBe('Rooms')
105
+ expect(buildBookingItem(legacy, { currentLanguage: 'es', mainLanguage: 'en' }).category).toBe(
106
+ 'Rooms'
107
+ )
108
+ // A row without category columns gains none.
109
+ expect(
110
+ 'category' in buildWithCategories(item, { currentLanguage: 'es', mainLanguage: 'en' })
111
+ ).toBe(false)
112
+ })
113
+
114
+ it('keeps the main language on its own pages and without a localization', () => {
115
+ expect(buildBookingItem(item, { currentLanguage: 'en', mainLanguage: 'en' }).name).toBe(
116
+ 'Double room'
117
+ )
118
+ expect(buildBookingItem(item, {}).name).toBe('Double room')
119
+ })
120
+ })
@@ -67,7 +67,10 @@ function bootTableRoute(tableName: string, options: BootOptions = {}) {
67
67
  options.cache
68
68
  )
69
69
  const code = esm
70
- .replace("import { Client } from 'pg'", "const { Client } = require('pg')")
70
+ .replace(
71
+ "import { Client, types as pgTypes } from 'pg'",
72
+ "const { Client, types: pgTypes } = require('pg')"
73
+ )
71
74
  .replace(
72
75
  "import { tqWithCache } from '../tq-cache/server'",
73
76
  "const { tqWithCache } = require('tq-cache')"
@@ -417,7 +420,10 @@ describe('raw-query state routes bind {{Current User.*}} to the session', () =>
417
420
  paramFields
418
421
  )
419
422
  const code = esm
420
- .replace("import { Client } from 'pg'", "const { Client } = require('pg')")
423
+ .replace(
424
+ "import { Client, types as pgTypes } from 'pg'",
425
+ "const { Client, types: pgTypes } = require('pg')"
426
+ )
421
427
  .replace(/^export default async function handler/m, 'async function handler')
422
428
  .concat('\nmodule.exports = { handler: handler }')
423
429
  const queries: Array<{ sql: string; params: unknown[] }> = []
@@ -335,7 +335,7 @@ describe('generateDataSourceFetcher', () => {
335
335
  const dataSource = createPostgreSQLDataSource('ds-1')
336
336
  const code = generateDataSourceFetcher(dataSource, 'users')
337
337
 
338
- expect(code).toContain('import { Client } from')
338
+ expect(code).toContain('import { Client, types as pgTypes } from')
339
339
  expect(code).toContain('export default async function handler')
340
340
  expect(code).toContain('SELECT * FROM "users"')
341
341
  expect(code).toContain('localhost')
@@ -157,7 +157,7 @@ describe('a list sort the editor fixed', () => {
157
157
  const code = await runPlugin('state', CACHE)
158
158
  expect(code).not.toContain('initialData=')
159
159
  expect(code).not.toContain('ds_0_cached')
160
- expect(code).toContain('field: sortBy.split("-")[0]')
160
+ expect(code).toContain('field: sortBy.split("-")?.[0]')
161
161
  })
162
162
  })
163
163
 
@@ -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
+ })