@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
@@ -0,0 +1,132 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+
4
+ /**
5
+ * A board's "No status" column lists the rows whose status is empty. The GUI
6
+ * sends that condition as `{ operand: '=', destination: null }` (and "has a
7
+ * status" as `'!='` + null), the one null shape every fetcher answers with
8
+ * `IS NULL` / `IS NOT NULL`. An operand the fetchers do not know is dropped
9
+ * with its null value — and a list whose only condition was dropped reads the
10
+ * whole table: every row in "No status", whose move button then overwrote the
11
+ * real statuses. Verdicts come from the SQL the executed route sends.
12
+ */
13
+
14
+ interface Captured {
15
+ sql: string
16
+ params: unknown[]
17
+ }
18
+
19
+ const bootRoute = (type: 'teleport' | 'postgresql') => {
20
+ const dataSource = {
21
+ id: 'ds-1',
22
+ name: 'db',
23
+ type,
24
+ config:
25
+ type === 'teleport'
26
+ ? {
27
+ host: 'teleporthq.secrets.TELEPORT_DB_HOST',
28
+ port: 5432,
29
+ user: 'teleporthq.secrets.TELEPORT_DB_USER',
30
+ password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
31
+ database: 'teleporthq.secrets.TELEPORT_DB_NAME',
32
+ selectedTables: {
33
+ team_events: { columns: [{ name: 'id' }, { name: 'priority' }] },
34
+ },
35
+ }
36
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
37
+ }
38
+ const code = generateDataSourceFetcherWithCore(dataSource as never, 'team_events', false, {})
39
+ .replace(
40
+ "import { Client, types as pgTypes } from 'pg'",
41
+ "const { Client, types: pgTypes } = require('pg')"
42
+ )
43
+ .replace(/^export default async function handler/m, 'async function handler')
44
+ .replace(/^export \{[^}]*\}\s*$/m, '')
45
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
46
+ .concat('\nmodule.exports = { handler: handler }')
47
+
48
+ const captured: Captured[] = []
49
+ class FakeClient {
50
+ public async connect(): Promise<void> {
51
+ return
52
+ }
53
+ public async end(): Promise<void> {
54
+ return
55
+ }
56
+ public async query(sql: string, params: unknown[] = []): Promise<{ rows: any[] }> {
57
+ captured.push({ sql, params })
58
+ return { rows: [] }
59
+ }
60
+ }
61
+ const moduleObj: { exports: any } = { exports: {} }
62
+ new Function('require', 'module', 'exports', 'process', 'console', code)(
63
+ (name: string) => {
64
+ if (name === 'pg') {
65
+ return { Client: FakeClient }
66
+ }
67
+ throw new Error('unexpected require: ' + name)
68
+ },
69
+ moduleObj,
70
+ moduleObj.exports,
71
+ { env: {} },
72
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
73
+ )
74
+
75
+ return async (filters: unknown): Promise<Captured> => {
76
+ captured.length = 0
77
+ let status = 0
78
+ const res = {
79
+ status(statusCode: number) {
80
+ status = statusCode
81
+ return this
82
+ },
83
+ json() {
84
+ return this
85
+ },
86
+ setHeader() {
87
+ return this
88
+ },
89
+ }
90
+ await moduleObj.exports.handler(
91
+ { method: 'GET', query: { filters: JSON.stringify(filters) }, headers: {} },
92
+ res
93
+ )
94
+ expect(status).toBe(200)
95
+ const select = captured.find((entry) => /FROM\s+"?team_events"?/i.test(entry.sql))
96
+ if (!select) {
97
+ throw new Error('the route never read team_events')
98
+ }
99
+ return select
100
+ }
101
+ }
102
+
103
+ // The domain → UIDL mapper's output for one condition in the inspector's group.
104
+ const condition = (operand: string, destination: unknown = null) => [
105
+ {
106
+ type: 'group',
107
+ operator: 'and',
108
+ children: [{ type: 'condition', source: 'priority', destination, operand }],
109
+ },
110
+ ]
111
+
112
+ describe.each(['teleport', 'postgresql'] as const)('the %s read route', (type) => {
113
+ const read = bootRoute(type)
114
+
115
+ it('answers "is empty" (= null) with IS NULL', async () => {
116
+ expect((await read(condition('='))).sql).toMatch(/WHERE\s+priority IS NULL/)
117
+ })
118
+
119
+ it('answers "is not empty" (!= null) with IS NOT NULL', async () => {
120
+ expect((await read(condition('!='))).sql).toMatch(/WHERE\s+priority IS NOT NULL/)
121
+ })
122
+
123
+ it('still binds a plain value as a parameter', async () => {
124
+ const select = await read(condition('=', 'high'))
125
+ expect(select.sql).toMatch(/WHERE\s+priority = \$1/)
126
+ expect(select.params).toEqual(expect.arrayContaining(['high']))
127
+ })
128
+
129
+ it('reads the whole table for an operand it does not know — why the mapper never sends one', async () => {
130
+ expect((await read(condition('is_null'))).sql).not.toMatch(/WHERE/)
131
+ })
132
+ })
@@ -0,0 +1,73 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+
4
+ /**
5
+ * A Postgres DATE is a calendar day. node-pg reads it as midnight in the
6
+ * server's time zone, so a server east of UTC served `2026-10-09` as
7
+ * `2026-10-08T21:00:00.000Z`: the list showed the day before, and an edit form
8
+ * that loaded the row saved that day back. Every Postgres fetcher keeps the
9
+ * day as its own text; a stand-in driver without `types` still loads.
10
+ */
11
+
12
+ type ParserSpy = Array<{ oid: number; parse: (value: string) => unknown }>
13
+
14
+ const bootFetcher = (type: 'teleport' | 'postgresql', withTypes: boolean): ParserSpy => {
15
+ const dataSource = {
16
+ id: 'ds-1',
17
+ name: 'db',
18
+ type,
19
+ config:
20
+ type === 'teleport'
21
+ ? {
22
+ host: 'teleporthq.secrets.TELEPORT_DB_HOST',
23
+ port: 5432,
24
+ user: 'teleporthq.secrets.TELEPORT_DB_USER',
25
+ password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
26
+ database: 'teleporthq.secrets.TELEPORT_DB_NAME',
27
+ selectedTables: { orders: { columns: [{ name: 'id' }, { name: 'due' }] } },
28
+ }
29
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
30
+ }
31
+ const code = generateDataSourceFetcherWithCore(dataSource as never, 'orders', false, {})
32
+ expect(code.startsWith("import { Client, types as pgTypes } from 'pg'")).toBe(true)
33
+ const runnable = code
34
+ .replace(
35
+ "import { Client, types as pgTypes } from 'pg'",
36
+ "const { Client, types: pgTypes } = require('pg')"
37
+ )
38
+ .replace(/^export default async function handler/m, 'async function handler')
39
+ .replace(/^export \{[^}]*\}\s*$/m, '')
40
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
41
+ const registered: ParserSpy = []
42
+ const types = {
43
+ setTypeParser: (oid: number, parse: (value: string) => unknown) =>
44
+ registered.push({ oid, parse }),
45
+ }
46
+ new Function('require', 'module', 'exports', 'process', 'console', runnable)(
47
+ (name: string) => {
48
+ if (name === 'pg') {
49
+ return withTypes ? { Client: class {}, types } : { Client: class {} }
50
+ }
51
+ throw new Error('unexpected require: ' + name)
52
+ },
53
+ { exports: {} },
54
+ {},
55
+ { env: {} },
56
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
57
+ )
58
+ return registered
59
+ }
60
+
61
+ describe.each(['teleport', 'postgresql'] as const)('%s fetcher DATE columns', (type) => {
62
+ it('keeps a DATE as the day it names', () => {
63
+ const registered = bootFetcher(type, true)
64
+ const date = registered.find(({ oid }) => oid === 1082)
65
+ expect(date).toBeDefined()
66
+ expect(date?.parse('2026-10-09')).toBe('2026-10-09')
67
+ expect(registered.map(({ oid }) => oid)).toEqual([1082])
68
+ })
69
+
70
+ it('loads with a driver that has no type registry', () => {
71
+ expect(bootFetcher(type, false)).toEqual([])
72
+ })
73
+ })
@@ -182,7 +182,7 @@ describe('a plain list fetches with its repeater’s sort', () => {
182
182
  it('sends a sort bound to state, and refetches when the state changes', async () => {
183
183
  const code = squash(await runPlugin('state'))
184
184
  expect(code).toContain(
185
- 'sorts: JSON.stringify([{ field: sortBy.split("-")[0], order: sortBy.split("-")[1] }])'
185
+ 'sorts: JSON.stringify([{ field: sortBy.split("-")?.[0], order: sortBy.split("-")?.[1] }])'
186
186
  )
187
187
  expect(code).toMatch(/\}\), \[sortBy\]\)\}/)
188
188
  })
@@ -121,8 +121,8 @@ describe('the generated Postgres route', () => {
121
121
  it('keeps a plain-column ORDER BY to fall back to', () => {
122
122
  // If a database ever rejects the sub-select, the page must degrade to the
123
123
  // previous ordering rather than serve nothing.
124
- expect(productsRoute).toContain('const usedDiscountAwareSort = orderBySql !== plainOrderBySql')
125
- expect(productsRoute).toContain('if (!usedDiscountAwareSort) throw sortError')
124
+ expect(productsRoute).toContain('const usedRewrittenSort = orderBySql !== plainOrderBySql')
125
+ expect(productsRoute).toContain('if (!usedRewrittenSort) throw sortError')
126
126
  expect(productsRoute).toContain('baseSql + plainOrderBySql + sqlTail')
127
127
  })
128
128
 
@@ -227,6 +227,13 @@ describe('stable pagination', () => {
227
227
  expect(rowIdentityColumn({}, 'teleport_orders')).toBeNull()
228
228
  })
229
229
 
230
+ it('breaks ties on every listing a visitor sorts: the blog listing and the booking page too', () => {
231
+ // "Sort by reading time" ties every five-minute post, "by price" every
232
+ // item at the same price.
233
+ expect(rowIdentityColumn({}, 'teleport_blog_posts')).toBe('id')
234
+ expect(rowIdentityColumn({}, 'teleport_booking_items')).toBe('id')
235
+ })
236
+
230
237
  it('is applied to the real and the fallback ORDER BY, and to a single sort column', () => {
231
238
  for (const route of [
232
239
  generatePostgreSQLFetcher(PG_CONFIG, 'teleport_products'),
@@ -274,10 +281,8 @@ describe('the fetcher a generated storefront actually gets', () => {
274
281
  })
275
282
 
276
283
  it('keeps the plain-column fallback and the tiebreaker', () => {
277
- expect(teleportProducts).toContain(
278
- 'const usedDiscountAwareSort = orderBySql !== plainOrderBySql'
279
- )
280
- expect(teleportProducts).toContain('if (!usedDiscountAwareSort) throw sortError')
284
+ expect(teleportProducts).toContain('const usedRewrittenSort = orderBySql !== plainOrderBySql')
285
+ expect(teleportProducts).toContain('if (!usedRewrittenSort) throw sortError')
281
286
  expect(teleportProducts).toContain('baseSql + plainOrderBySql + sqlTail')
282
287
  expect(teleportProducts).toContain('const rowOrderTiebreak = (fields, direction) =>')
283
288
  expect(teleportProducts).toMatch(/\$\{tiebreak\}/)
@@ -0,0 +1,159 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import { ComponentStructure } from '@teleporthq/teleport-types'
4
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
5
+ import {
6
+ makeUidlNode,
7
+ makeWidgetComponentChunk,
8
+ RANGE_DRAWN_TEXT,
9
+ type RepeaterOptions,
10
+ } from './_helpers/pagination-widget-fixture'
11
+
12
+ type ControlOptions = Parameters<typeof makeWidgetComponentChunk>[0]
13
+
14
+ const runPlugin = async (repeater: RepeaterOptions, controls: ControlOptions): Promise<string> => {
15
+ const chunk = makeWidgetComponentChunk(controls)
16
+ const structure: ComponentStructure = {
17
+ uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
18
+ chunks: [chunk],
19
+ dependencies: {},
20
+ options: { dataSources: {}, extractedResources: {} },
21
+ } as never
22
+ await createNextArrayMapperPaginationPlugin()(structure)
23
+ return generator(chunk.content as types.Node).code
24
+ }
25
+
26
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
27
+
28
+ const TOTAL_STATE = 'const [ds_0_total, setDs_0_total] = useState(null)'
29
+ const DRAWN = `ds_0_total === null ? "${RANGE_DRAWN_TEXT}" :`
30
+ const FROM_BY_PAGE = '(ds_0_total === 0 ? 0 : (ds_0_page - 1) * 20 + 1).toLocaleString("en-US")'
31
+ const FROM_FIRST_ROW = '(ds_0_total === 0 ? 0 : 1).toLocaleString("en-US")'
32
+ const TO = 'Math.min(ds_0_page * 20, ds_0_total).toLocaleString("en-US")'
33
+ const TOTAL = 'ds_0_total.toLocaleString("en-US")'
34
+
35
+ describe('pagination plugin — range control', () => {
36
+ it('fills the authored words with the page window in buttons mode', async () => {
37
+ const flat = collapse(await runPlugin({}, { controls: ['previous', 'range', 'next'] }))
38
+
39
+ // The total is a state of its own — `maxPages` cannot give it back — and
40
+ // the count effect fills both from the same response.
41
+ expect(flat).toContain(TOTAL_STATE)
42
+ expect(flat).toContain('setDs_0_maxPages(')
43
+ expect(flat).toContain('setDs_0_total(data.count)')
44
+
45
+ // The drawn text stays until the count has answered; then the template.
46
+ expect(flat).toContain(`${DRAWN} \`Showing \${${FROM_BY_PAGE}}–\${${TO}} of \${${TOTAL}}\``)
47
+ // The marker and template ride along, so the DOM carries the contract.
48
+ expect(flat).toContain('data-tq-pagination-control="range"')
49
+ expect(flat).toContain('data-tq-range-template=')
50
+ })
51
+
52
+ it('counts from the first row in an appending list and survives the mode pruning', async () => {
53
+ const flat = collapse(
54
+ await runPlugin(
55
+ { infiniteScroll: true, infiniteScrollLoadMore: true },
56
+ { controls: ['previous', 'range', 'load-more', 'next'] }
57
+ )
58
+ )
59
+
60
+ // Previous/Next are pruned for an appending list; the range is not.
61
+ expect(flat).not.toContain('thq-previous-elm')
62
+ expect(flat).not.toContain('thq-next-elm')
63
+ expect(flat).toContain('thq-load-more-elm')
64
+ expect(flat).toContain('data-tq-pagination-control="range"')
65
+
66
+ // Everything loaded so far: 1 .. min(pages × perPage, total).
67
+ expect(flat).toContain(`${DRAWN} \`Showing \${${FROM_FIRST_ROW}}–\${${TO}} of \${${TOTAL}}\``)
68
+ expect(flat).not.toContain(FROM_BY_PAGE)
69
+ })
70
+
71
+ it('is kept and wired beside a sentinel-driven infinite scroll too', async () => {
72
+ const flat = collapse(await runPlugin({ infiniteScroll: true }, { controls: ['range'] }))
73
+
74
+ expect(flat).toContain('ds_0_sentinelRef')
75
+ expect(flat).toContain(TOTAL_STATE)
76
+ expect(flat).toContain(FROM_FIRST_ROW)
77
+ })
78
+
79
+ it('survives numbered mode with the page window', async () => {
80
+ const flat = collapse(
81
+ await runPlugin(
82
+ { paginationMode: 'numbered' },
83
+ { controls: ['first', 'previous', 'pages', 'range', 'next', 'last'] }
84
+ )
85
+ )
86
+
87
+ expect(flat).toContain('ds_0_pageTokens.map(token =>')
88
+ expect(flat).toContain('data-tq-pagination-control="range"')
89
+ expect(flat).toContain(FROM_BY_PAGE)
90
+ })
91
+
92
+ it('reads the page off the combined state beside a search input', async () => {
93
+ const flat = collapse(
94
+ await runPlugin({ searchEnabled: true }, { controls: ['previous', 'range', 'next'] })
95
+ )
96
+
97
+ expect(flat).toContain(TOTAL_STATE)
98
+ expect(flat).toContain('setDs_0_total(data.count)')
99
+ expect(flat).toContain(
100
+ '(ds_0_total === 0 ? 0 : (ds_0_state.page - 1) * 20 + 1).toLocaleString("en-US")'
101
+ )
102
+ expect(flat).toContain('Math.min(ds_0_state.page * 20, ds_0_total).toLocaleString("en-US")')
103
+ })
104
+
105
+ it('reads a template the JSX printer escaped the braces of', async () => {
106
+ const flat = collapse(
107
+ await runPlugin(
108
+ {},
109
+ {
110
+ controls: ['range'],
111
+ rangeTemplate: 'Showing &#123;from&#125;–&#123;to&#125; of &#123;total&#125;',
112
+ }
113
+ )
114
+ )
115
+ expect(flat).not.toContain('`Showing &#123;')
116
+ expect(flat).toMatch(/`Showing \$\{[^`]*–\$\{[^`]*of \$\{[^`]*`/)
117
+ })
118
+
119
+ it('falls back to "{from}–{to} of {total}" when no template is authored', async () => {
120
+ const flat = collapse(await runPlugin({}, { controls: ['range'], rangeTemplate: null }))
121
+
122
+ expect(flat).not.toContain('data-tq-range-template')
123
+ expect(flat).toContain(`${DRAWN} \`\${${FROM_BY_PAGE}}–\${${TO}} of \${${TOTAL}}\``)
124
+ })
125
+
126
+ it('wires a range outside every widget to the mapper it names, wherever it sits', async () => {
127
+ // Placed BEFORE the provider — a toolbar above the list — so the
128
+ // container-ownership rule could never have reached it.
129
+ const flat = collapse(
130
+ await runPlugin(
131
+ { nodeId: 'mapper-1' },
132
+ { controls: ['previous', 'next'], standaloneRange: { forNodeId: 'mapper-1' } }
133
+ )
134
+ )
135
+
136
+ expect(flat).toContain('data-tq-pagination-for="mapper-1"')
137
+ expect(flat).toContain(TOTAL_STATE)
138
+ expect(flat).toContain(`${DRAWN} \`Showing \${${FROM_BY_PAGE}}–\${${TO}} of \${${TOTAL}}\``)
139
+ })
140
+
141
+ it('leaves a range naming a mapper this component does not render as drawn', async () => {
142
+ const flat = collapse(
143
+ await runPlugin(
144
+ { nodeId: 'mapper-1' },
145
+ { controls: ['previous', 'next'], standaloneRange: { forNodeId: 'someone-else' } }
146
+ )
147
+ )
148
+
149
+ expect(flat).toContain(`>${RANGE_DRAWN_TEXT}</div>`)
150
+ expect(flat).not.toContain('ds_0_total === null')
151
+ })
152
+
153
+ it('changes nothing for a component without a range control', async () => {
154
+ const flat = collapse(await runPlugin({}, { controls: ['previous', 'next'] }))
155
+
156
+ expect(flat).not.toContain('ds_0_total')
157
+ expect(flat).not.toContain('setDs_0_total')
158
+ })
159
+ })
@@ -0,0 +1,86 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import { ComponentStructure } from '@teleporthq/teleport-types'
4
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
5
+ import {
6
+ makeMultiMapperUidl,
7
+ makeWidgetComponentChunk,
8
+ type RepeaterOptions,
9
+ } from './_helpers/pagination-widget-fixture'
10
+
11
+ type ControlOptions = Parameters<typeof makeWidgetComponentChunk>[0]
12
+
13
+ /**
14
+ * A page's OWN search box, marked `data-tq-search-for` with its mapper's node
15
+ * id, is wired like the platform's `search-input`: controlled by the mapper's
16
+ * query state. It is paired by the id it names, never by its place among the
17
+ * platform inputs.
18
+ */
19
+
20
+ const runPlugin = async (
21
+ repeaters: RepeaterOptions[],
22
+ controls: ControlOptions
23
+ ): Promise<string> => {
24
+ const chunk = makeWidgetComponentChunk(controls)
25
+ const structure: ComponentStructure = {
26
+ uidl: { name: 'TestComponent', node: makeMultiMapperUidl(repeaters) },
27
+ chunks: [chunk],
28
+ dependencies: {},
29
+ options: { dataSources: {}, extractedResources: {} },
30
+ } as never
31
+ await createNextArrayMapperPaginationPlugin()(structure)
32
+ return generator(chunk.content as types.Node).code
33
+ }
34
+
35
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
36
+
37
+ const wiredTo = (index: number): string =>
38
+ `onChange={e => setDs_${index}_searchQuery(e.target.value)} value={ds_${index}_searchQuery}`
39
+
40
+ describe('pagination plugin — a page’s own search box', () => {
41
+ it('controls a marked input by the query of the mapper it names, and keeps Enter from submitting', async () => {
42
+ const flat = collapse(
43
+ await runPlugin([{ searchEnabled: true, nodeId: 'TQ_orders' }], {
44
+ controls: ['previous', 'next'],
45
+ standaloneSearch: [{ name: 'hm-search', forNodeId: 'TQ_orders' }],
46
+ })
47
+ )
48
+ expect(flat).toContain(`name="hm-search" data-tq-search-for="TQ_orders" ${wiredTo(0)}`)
49
+ expect(flat).toContain('onKeyDown={e => { if (e.key === "Enter") e.preventDefault(); }}')
50
+ })
51
+
52
+ it('pairs by the id named, whatever the order: the platform input keeps its place', async () => {
53
+ const flat = collapse(
54
+ await runPlugin(
55
+ [
56
+ { searchEnabled: true, nodeId: 'TQ_jobs' },
57
+ { searchEnabled: true, nodeId: 'TQ_orders' },
58
+ ],
59
+ {
60
+ controls: ['previous', 'next'],
61
+ mappers: 2,
62
+ standaloneSearch: [
63
+ { name: 'hm-search', forNodeId: 'TQ_orders' },
64
+ { name: 'platform-search' },
65
+ ],
66
+ }
67
+ )
68
+ )
69
+ // The marked box searches the second mapper; the platform box, first of its kind, the first.
70
+ expect(flat).toContain(`data-tq-search-for="TQ_orders" ${wiredTo(1)}`)
71
+ expect(flat).toContain(
72
+ `name="platform-search" className="products-list-search-input" ${wiredTo(0)}`
73
+ )
74
+ })
75
+
76
+ it('leaves a marked input alone when no search-enabled mapper carries its id', async () => {
77
+ const flat = collapse(
78
+ await runPlugin([{ searchEnabled: true, nodeId: 'TQ_orders' }], {
79
+ controls: ['previous', 'next'],
80
+ standaloneSearch: [{ name: 'hm-search', forNodeId: 'TQ_elsewhere' }],
81
+ })
82
+ )
83
+ expect(flat).toContain('name="hm-search" data-tq-search-for="TQ_elsewhere" />')
84
+ expect(flat).not.toContain('setDs_0_searchQuery(e.target.value)')
85
+ })
86
+ })
@@ -40,7 +40,7 @@ const makeComponentChunk = (): ChunkDefinition => {
40
40
  // A `data-source-list > cms-list-repeater` UIDL: a paginated + search-enabled
41
41
  // products list, optionally bound to a URL search-param key.
42
42
  // tslint:disable-next-line:no-any
43
- const makeUidlNode = (searchUrlParamKey?: string): any => ({
43
+ const makeUidlNode = (searchUrlParamKey?: string, pageUrlParamKey?: string): any => ({
44
44
  type: 'data-source-list',
45
45
  content: {
46
46
  renderPropIdentifier: 'items',
@@ -60,6 +60,7 @@ const makeUidlNode = (searchUrlParamKey?: string): any => ({
60
60
  searchEnabled: true,
61
61
  searchDebounce: 300,
62
62
  ...(searchUrlParamKey ? { searchUrlParamKey } : {}),
63
+ ...(pageUrlParamKey ? { pageUrlParamKey } : {}),
63
64
  nodes: { list: { type: 'element', content: { elementType: 'div' } } },
64
65
  },
65
66
  },
@@ -67,10 +68,17 @@ const makeUidlNode = (searchUrlParamKey?: string): any => ({
67
68
  },
68
69
  })
69
70
 
70
- const runPlugin = async (searchUrlParamKey?: string, paginated = true): Promise<string> => {
71
+ interface RunOptions {
72
+ paginated?: boolean
73
+ pageUrlParamKey?: string
74
+ infiniteScroll?: boolean
75
+ }
76
+
77
+ const runPlugin = async (searchUrlParamKey?: string, options: RunOptions = {}): Promise<string> => {
71
78
  const chunk = makeComponentChunk()
72
- const node = makeUidlNode(searchUrlParamKey)
73
- node.content.nodes.success.content.paginated = paginated
79
+ const node = makeUidlNode(searchUrlParamKey, options.pageUrlParamKey)
80
+ node.content.nodes.success.content.paginated = options.paginated ?? true
81
+ node.content.nodes.success.content.infiniteScroll = !!options.infiniteScroll
74
82
  const structure: ComponentStructure = {
75
83
  uidl: { name: 'TestComponent', node },
76
84
  chunks: [chunk],
@@ -108,19 +116,37 @@ describe('pagination plugin — search input URL two-way sync (searchUrlParamKey
108
116
  )
109
117
  expect(code).toContain('}, [ds_0_state.debouncedQuery, router.isReady])')
110
118
 
111
- // Read-back (URL → input AND applied query at once, keeping the page),
112
- // functional setState bail-out (loop-free), then the handshake flag.
119
+ // Read-back (URL → input AND applied query at once, from page 1: the page
120
+ // is not in the address), functional setState bail-out (loop-free), then
121
+ // the handshake flag.
113
122
  expect(code).toContain('const __urlValue = router.query.searchKeyword')
114
123
  expect(code).toContain('ds_0_adoptUrlSearch(prev => prev === __nextValue ? prev : __nextValue)')
115
124
  expect(code).toContain('ds_0_searchUrlAdopted.current = true')
116
125
  expect(code).toContain('}, [router.query.searchKeyword, router.isReady])')
117
126
  expect(code.replace(/\s+/g, ' ')).toContain(
118
- 'const ds_0_adoptUrlSearch = update => { setDs_0_searchQuery(update); setDs_0_state(state => { const next = update(state.debouncedQuery); return next === state.debouncedQuery ? state : { ...state, debouncedQuery: next }; }); };'
127
+ 'const ds_0_adoptUrlSearch = update => { setDs_0_searchQuery(update); setDs_0_state(state => { const next = update(state.debouncedQuery); return next === state.debouncedQuery ? state : { ...state, page: 1, debouncedQuery: next }; }); };'
128
+ )
129
+ })
130
+
131
+ it('starts an appending list on page 1 when the address brings a query', async () => {
132
+ // The scroll sentinel has usually fetched page 2 before the router is
133
+ // ready: kept, the query's first answer was its page 2 — empty when only a
134
+ // few rows match.
135
+ const code = await runPlugin('q', { infiniteScroll: true })
136
+ expect(code.replace(/\s+/g, ' ')).toContain(
137
+ 'return next === state.debouncedQuery ? state : { ...state, page: 1, debouncedQuery: next };'
138
+ )
139
+ })
140
+
141
+ it('keeps the page of a list whose page is in the address too', async () => {
142
+ const code = await runPlugin('searchKeyword', { pageUrlParamKey: 'page' })
143
+ expect(code.replace(/\s+/g, ' ')).toContain(
144
+ 'return next === state.debouncedQuery ? state : { ...state, debouncedQuery: next };'
119
145
  )
120
146
  })
121
147
 
122
148
  it('adopts the address into both queries of a list without pages too', async () => {
123
- const code = await runPlugin('searchKeyword', false)
149
+ const code = await runPlugin('searchKeyword', { paginated: false })
124
150
  expect(code).not.toContain('window.location.search')
125
151
  expect(code.replace(/\s+/g, ' ')).toContain(
126
152
  'const ds_0_adoptUrlSearch = update => { setDs_0_searchQuery(update); setDs_0_debouncedQuery(update); };'
@@ -62,7 +62,10 @@ function bootTableRoute(
62
62
  })
63
63
  // The route is an ES module; run it as a script with the same bindings.
64
64
  const code = esm
65
- .replace("import { Client } from 'pg'", "const { Client } = require('pg')")
65
+ .replace(
66
+ "import { Client, types as pgTypes } from 'pg'",
67
+ "const { Client, types: pgTypes } = require('pg')"
68
+ )
66
69
  .replace(/^export default async function handler/m, 'async function handler')
67
70
  .replace(/^export \{[^}]*\}\s*$/m, '')
68
71
  .replace(/^export default \{[^}]*\}\s*$/m, '')
@@ -436,7 +439,7 @@ describe('buildProductTransformOptions — the roles reach the emitted module',
436
439
  })
437
440
 
438
441
  describe('resolveTrustedReaderRoles', () => {
439
- it('reads the roles off the admin folder and the admin pages, and nothing else', () => {
442
+ it("reads the roles off the admin folder, never off one admin page's own roles", () => {
440
443
  const roles = TableAccess.resolveTrustedReaderRoles({
441
444
  enabled: true,
442
445
  dataSourceId: 'ds-1',
@@ -487,7 +490,34 @@ describe('resolveTrustedReaderRoles', () => {
487
490
  envKeys: {},
488
491
  customUserProperties: [],
489
492
  })
490
- expect(roles).toEqual(['admin', 'staff'])
493
+ // `staff` opens `/admin/orders` only: a page's role is no reader of every
494
+ // protected table (an admin page may also admit staff).
495
+ expect(roles).toEqual(['admin'])
491
496
  expect(TableAccess.resolveTrustedReaderRoles(undefined)).toEqual([])
492
497
  })
498
+
499
+ it('refuses staff admitted to some admin pages the accounts table on its read route', async () => {
500
+ const staffPagesAuth: UIDLAuthentication = {
501
+ ...ADMIN_PANEL_AUTH,
502
+ roles: ['admin', 'staff', 'user'],
503
+ pageProtection: {
504
+ dashboard: {
505
+ requiresAuth: true,
506
+ allowedRoles: ['admin', 'staff'],
507
+ pageName: 'dashboard',
508
+ route: '/admin/dashboard',
509
+ },
510
+ tasks: {
511
+ requiresAuth: true,
512
+ allowedRoles: ['admin', 'staff'],
513
+ pageName: 'tasks',
514
+ route: '/admin/tasks',
515
+ },
516
+ },
517
+ }
518
+ const options = buildProductTransformOptions({ authentication: staffPagesAuth })
519
+ const users = bootTableRoute('users', options.trustedReaderRoles || [])
520
+ expect((await users({ handler: 'handler', session: { role: 'staff' } })).status).toBe(403)
521
+ expect((await users({ handler: 'handler', session: { role: 'admin' } })).status).toBe(200)
522
+ })
493
523
  })
@@ -19,6 +19,12 @@ interface CountEffectOptions {
19
19
  perPage: number;
20
20
  /** Name of the `setDs_N_maxPages` setter. */
21
21
  setMaxPagesVar: string;
22
+ /**
23
+ * Name of the `setDs_N_total` setter, declared only when the component draws
24
+ * a `range` control ("Showing 1–8 of 12"): the page count alone cannot say
25
+ * how many rows there are.
26
+ */
27
+ setTotalVar?: string;
22
28
  /**
23
29
  * Params sent to the count endpoint: the search query (when the list has
24
30
  * one), `queryColumns`, and the filter array. Built by the caller because
@@ -38,7 +44,7 @@ interface CountEffectOptions {
38
44
  }
39
45
  /** `const ds_N_countSeq = useRef(0)` */
40
46
  export declare const buildCountSeqDeclaration: (countSeqRefVar: string) => types.Statement;
41
- export declare const buildCountFetchStatement: (options: Pick<CountEffectOptions, 'fileName' | 'perPage' | 'setMaxPagesVar' | 'urlParams' | 'countSeqRefVar'>) => types.Statement;
47
+ export declare const buildCountFetchStatement: (options: Pick<CountEffectOptions, 'fileName' | 'perPage' | 'setMaxPagesVar' | 'setTotalVar' | 'urlParams' | 'countSeqRefVar'>) => types.Statement;
42
48
  /**
43
49
  * The whole `useEffect(...)` wrapper.
44
50
  *
@@ -1 +1 @@
1
- {"version":3,"file":"count-effect.d.ts","sourceRoot":"","sources":["../../src/count-effect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAErC;;;;;;;;;;;;GAYG;AAEH,UAAU,kBAAkB;IAC1B,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAA;IACf,6CAA6C;IAC7C,cAAc,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,SAAS,EAAE,KAAK,CAAC,cAAc,EAAE,CAAA;IACjC,iDAAiD;IACjD,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAA;IACxB;;;;;;OAMG;IACH,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,wCAAwC;AACxC,eAAO,MAAM,wBAAwB,mBAAoB,MAAM,KAAG,MAAM,SAMpE,CAAA;AAqDJ,eAAO,MAAM,wBAAwB,YAC1B,KACP,kBAAkB,EAClB,UAAU,GAAG,SAAS,GAAG,gBAAgB,GAAG,WAAW,GAAG,gBAAgB,CAC3E,KACA,MAAM,SA2DN,CAAA;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,YAAa,kBAAkB,KAAG,MAAM,mBAYvE,CAAA"}
1
+ {"version":3,"file":"count-effect.d.ts","sourceRoot":"","sources":["../../src/count-effect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAErC;;;;;;;;;;;;GAYG;AAEH,UAAU,kBAAkB;IAC1B,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAA;IACf,6CAA6C;IAC7C,cAAc,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,SAAS,EAAE,KAAK,CAAC,cAAc,EAAE,CAAA;IACjC,iDAAiD;IACjD,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAA;IACxB;;;;;;OAMG;IACH,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,wCAAwC;AACxC,eAAO,MAAM,wBAAwB,mBAAoB,MAAM,KAAG,MAAM,SAMpE,CAAA;AA4EJ,eAAO,MAAM,wBAAwB,YAC1B,KACP,kBAAkB,EAClB,UAAU,GAAG,SAAS,GAAG,gBAAgB,GAAG,aAAa,GAAG,WAAW,GAAG,gBAAgB,CAC3F,KACA,MAAM,SAqDN,CAAA;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,YAAa,kBAAkB,KAAG,MAAM,mBAYvE,CAAA"}