@teleporthq/teleport-plugin-next-data-source 0.43.65 → 0.43.67

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 (179) hide show
  1. package/__tests__/array-mapper-cache-wiring.test.ts +11 -0
  2. package/__tests__/data-source-request-locale.test.ts +313 -0
  3. package/__tests__/gated-data-provider.test.ts +88 -0
  4. package/__tests__/localized-columns.test.ts +217 -0
  5. package/__tests__/numbered-pagination.test.ts +15 -0
  6. package/__tests__/product-filter-fields.ts +230 -0
  7. package/__tests__/product-filter-sql.fixture.json +7 -0
  8. package/__tests__/refetch-loading-state.test.ts +23 -0
  9. package/__tests__/search-clear-button.test.ts +122 -0
  10. package/dist/cjs/content-localization.d.ts +37 -0
  11. package/dist/cjs/content-localization.d.ts.map +1 -0
  12. package/dist/cjs/content-localization.js +29 -0
  13. package/dist/cjs/content-localization.js.map +1 -0
  14. package/dist/cjs/count-effect.d.ts +11 -1
  15. package/dist/cjs/count-effect.d.ts.map +1 -1
  16. package/dist/cjs/count-effect.js +30 -2
  17. package/dist/cjs/count-effect.js.map +1 -1
  18. package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/airtable.js +1 -1
  20. package/dist/cjs/fetchers/airtable.js.map +1 -1
  21. package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/clickhouse.js +1 -1
  23. package/dist/cjs/fetchers/clickhouse.js.map +1 -1
  24. package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
  25. package/dist/cjs/fetchers/firestore.js +1 -1
  26. package/dist/cjs/fetchers/firestore.js.map +1 -1
  27. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  28. package/dist/cjs/fetchers/mariadb.js +1 -1
  29. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  30. package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
  31. package/dist/cjs/fetchers/mongodb.js +1 -1
  32. package/dist/cjs/fetchers/mongodb.js.map +1 -1
  33. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/mysql.js +1 -1
  35. package/dist/cjs/fetchers/mysql.js.map +1 -1
  36. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/postgresql.js +2 -1
  38. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  39. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/redshift.js +1 -1
  41. package/dist/cjs/fetchers/redshift.js.map +1 -1
  42. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/teleport.js +10 -3
  44. package/dist/cjs/fetchers/teleport.js.map +1 -1
  45. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  46. package/dist/cjs/fetchers/turso.js +1 -1
  47. package/dist/cjs/fetchers/turso.js.map +1 -1
  48. package/dist/cjs/index.d.ts +5 -0
  49. package/dist/cjs/index.d.ts.map +1 -1
  50. package/dist/cjs/index.js +60 -8
  51. package/dist/cjs/index.js.map +1 -1
  52. package/dist/cjs/loading-state.d.ts +16 -1
  53. package/dist/cjs/loading-state.d.ts.map +1 -1
  54. package/dist/cjs/loading-state.js +69 -3
  55. package/dist/cjs/loading-state.js.map +1 -1
  56. package/dist/cjs/localized-columns.d.ts +56 -0
  57. package/dist/cjs/localized-columns.d.ts.map +1 -0
  58. package/dist/cjs/localized-columns.js +124 -0
  59. package/dist/cjs/localized-columns.js.map +1 -0
  60. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  61. package/dist/cjs/pagination-plugin.js +123 -45
  62. package/dist/cjs/pagination-plugin.js.map +1 -1
  63. package/dist/cjs/product-filter-fields.d.ts +87 -0
  64. package/dist/cjs/product-filter-fields.d.ts.map +1 -0
  65. package/dist/cjs/product-filter-fields.js +161 -0
  66. package/dist/cjs/product-filter-fields.js.map +1 -0
  67. package/dist/cjs/request-locale.d.ts +49 -0
  68. package/dist/cjs/request-locale.d.ts.map +1 -0
  69. package/dist/cjs/request-locale.js +130 -0
  70. package/dist/cjs/request-locale.js.map +1 -0
  71. package/dist/cjs/search-clear.d.ts +31 -0
  72. package/dist/cjs/search-clear.d.ts.map +1 -0
  73. package/dist/cjs/search-clear.js +103 -0
  74. package/dist/cjs/search-clear.js.map +1 -0
  75. package/dist/cjs/transformations/index.d.ts +16 -2
  76. package/dist/cjs/transformations/index.d.ts.map +1 -1
  77. package/dist/cjs/transformations/index.js +14 -2
  78. package/dist/cjs/transformations/index.js.map +1 -1
  79. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  80. package/dist/cjs/utils.d.ts +11 -1
  81. package/dist/cjs/utils.d.ts.map +1 -1
  82. package/dist/cjs/utils.js +59 -15
  83. package/dist/cjs/utils.js.map +1 -1
  84. package/dist/esm/content-localization.d.ts +37 -0
  85. package/dist/esm/content-localization.d.ts.map +1 -0
  86. package/dist/esm/content-localization.js +24 -0
  87. package/dist/esm/content-localization.js.map +1 -0
  88. package/dist/esm/count-effect.d.ts +11 -1
  89. package/dist/esm/count-effect.d.ts.map +1 -1
  90. package/dist/esm/count-effect.js +28 -1
  91. package/dist/esm/count-effect.js.map +1 -1
  92. package/dist/esm/fetchers/airtable.d.ts.map +1 -1
  93. package/dist/esm/fetchers/airtable.js +1 -1
  94. package/dist/esm/fetchers/airtable.js.map +1 -1
  95. package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
  96. package/dist/esm/fetchers/clickhouse.js +1 -1
  97. package/dist/esm/fetchers/clickhouse.js.map +1 -1
  98. package/dist/esm/fetchers/firestore.d.ts.map +1 -1
  99. package/dist/esm/fetchers/firestore.js +1 -1
  100. package/dist/esm/fetchers/firestore.js.map +1 -1
  101. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  102. package/dist/esm/fetchers/mariadb.js +1 -1
  103. package/dist/esm/fetchers/mariadb.js.map +1 -1
  104. package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
  105. package/dist/esm/fetchers/mongodb.js +1 -1
  106. package/dist/esm/fetchers/mongodb.js.map +1 -1
  107. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  108. package/dist/esm/fetchers/mysql.js +1 -1
  109. package/dist/esm/fetchers/mysql.js.map +1 -1
  110. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  111. package/dist/esm/fetchers/postgresql.js +2 -1
  112. package/dist/esm/fetchers/postgresql.js.map +1 -1
  113. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  114. package/dist/esm/fetchers/redshift.js +1 -1
  115. package/dist/esm/fetchers/redshift.js.map +1 -1
  116. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  117. package/dist/esm/fetchers/teleport.js +10 -3
  118. package/dist/esm/fetchers/teleport.js.map +1 -1
  119. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  120. package/dist/esm/fetchers/turso.js +1 -1
  121. package/dist/esm/fetchers/turso.js.map +1 -1
  122. package/dist/esm/index.d.ts +5 -0
  123. package/dist/esm/index.d.ts.map +1 -1
  124. package/dist/esm/index.js +55 -7
  125. package/dist/esm/index.js.map +1 -1
  126. package/dist/esm/loading-state.d.ts +16 -1
  127. package/dist/esm/loading-state.d.ts.map +1 -1
  128. package/dist/esm/loading-state.js +69 -3
  129. package/dist/esm/loading-state.js.map +1 -1
  130. package/dist/esm/localized-columns.d.ts +56 -0
  131. package/dist/esm/localized-columns.d.ts.map +1 -0
  132. package/dist/esm/localized-columns.js +119 -0
  133. package/dist/esm/localized-columns.js.map +1 -0
  134. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  135. package/dist/esm/pagination-plugin.js +124 -46
  136. package/dist/esm/pagination-plugin.js.map +1 -1
  137. package/dist/esm/product-filter-fields.d.ts +87 -0
  138. package/dist/esm/product-filter-fields.d.ts.map +1 -0
  139. package/dist/esm/product-filter-fields.js +154 -0
  140. package/dist/esm/product-filter-fields.js.map +1 -0
  141. package/dist/esm/request-locale.d.ts +49 -0
  142. package/dist/esm/request-locale.d.ts.map +1 -0
  143. package/dist/esm/request-locale.js +98 -0
  144. package/dist/esm/request-locale.js.map +1 -0
  145. package/dist/esm/search-clear.d.ts +31 -0
  146. package/dist/esm/search-clear.d.ts.map +1 -0
  147. package/dist/esm/search-clear.js +74 -0
  148. package/dist/esm/search-clear.js.map +1 -0
  149. package/dist/esm/transformations/index.d.ts +16 -2
  150. package/dist/esm/transformations/index.d.ts.map +1 -1
  151. package/dist/esm/transformations/index.js +14 -2
  152. package/dist/esm/transformations/index.js.map +1 -1
  153. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  154. package/dist/esm/utils.d.ts +11 -1
  155. package/dist/esm/utils.d.ts.map +1 -1
  156. package/dist/esm/utils.js +59 -15
  157. package/dist/esm/utils.js.map +1 -1
  158. package/package.json +5 -5
  159. package/src/content-localization.ts +53 -0
  160. package/src/count-effect.ts +53 -2
  161. package/src/fetchers/airtable.ts +4 -2
  162. package/src/fetchers/clickhouse.ts +4 -2
  163. package/src/fetchers/firestore.ts +4 -2
  164. package/src/fetchers/mariadb.ts +4 -2
  165. package/src/fetchers/mongodb.ts +4 -2
  166. package/src/fetchers/mysql.ts +4 -2
  167. package/src/fetchers/postgresql.ts +16 -2
  168. package/src/fetchers/redshift.ts +4 -2
  169. package/src/fetchers/teleport.ts +48 -9
  170. package/src/fetchers/turso.ts +4 -2
  171. package/src/index.ts +79 -11
  172. package/src/loading-state.ts +134 -3
  173. package/src/localized-columns.ts +133 -0
  174. package/src/pagination-plugin.ts +177 -53
  175. package/src/product-filter-fields.ts +173 -0
  176. package/src/request-locale.ts +113 -0
  177. package/src/search-clear.ts +102 -0
  178. package/src/transformations/index.ts +30 -5
  179. package/src/utils.ts +83 -15
@@ -285,6 +285,17 @@ describe('array-mapper cache — client wiring', () => {
285
285
  expect(hitReturn).toBeLessThan(raise)
286
286
  })
287
287
 
288
+ it('counts a cache hit as the newest settled result', async () => {
289
+ const { code } = await runPlugin({ cache: CACHE })
290
+
291
+ // A hit answers synchronously with THIS request's rows; without advancing
292
+ // the sequence, an older network request still in flight would resolve
293
+ // after it and paint over it.
294
+ expect(code).toMatch(
295
+ /if \(__tqHit !== undefined\) \{\s*\+\+ds_0_fetchSeq\.current;?\s*ds_0_latestData\.current = __tqHit;?\s*return Promise\.resolve\(__tqHit\)/
296
+ )
297
+ })
298
+
288
299
  it('still tracks in-flight fetches on a cache MISS', async () => {
289
300
  const { code } = await runPlugin({ cache: CACHE })
290
301
 
@@ -0,0 +1,313 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ GeneratorOptions,
9
+ UIDLDataSource,
10
+ UIDLDependency,
11
+ } from '@teleporthq/teleport-types'
12
+ import { createNextPagesDataSourcePlugin, createNextComponentDataSourcePlugin } from '../src/index'
13
+
14
+ /**
15
+ * A page in a multi-language project renders rows in ITS language: every
16
+ * data-source fetch — the build-time prefetch in getStaticProps and the
17
+ * client-side refetch behind pagination, search and filters — says which
18
+ * locale it is for, and a locale switch (a client-side transition on the same
19
+ * page instance) refetches. A single-language project generates exactly what
20
+ * it generated before: no locale, no router.
21
+ */
22
+
23
+ const DS_ID = 'ds1'
24
+ const TABLE = 'teleport_products'
25
+ /** A page writes the fetcher to `utils/`, a component straight into `api/`. */
26
+ const FETCHER_KEYS = ['utils/teleport-teleport_products-ds1', 'api/teleport-teleport_products-ds1']
27
+
28
+ const I18N: GeneratorOptions['internationalization'] = {
29
+ main: { name: 'English', locale: 'en' },
30
+ languages: { en: 'English', es: 'Spanish' },
31
+ }
32
+
33
+ const dataSource: UIDLDataSource = {
34
+ id: DS_ID,
35
+ name: 'TeleportHQ database',
36
+ type: 'teleport',
37
+ config: {
38
+ selectedTables: {
39
+ [TABLE]: {
40
+ columns: [{ name: 'id' }, { name: 'name' }, { name: 'slug' }, { name: 'es_name' }],
41
+ },
42
+ },
43
+ },
44
+ }
45
+
46
+ const jsx = (name: string, attrs: types.JSXAttribute[], children: types.JSXElement[] = []) =>
47
+ types.jsxElement(
48
+ types.jsxOpeningElement(types.jsxIdentifier(name), attrs, children.length === 0),
49
+ children.length === 0 ? null : types.jsxClosingElement(types.jsxIdentifier(name)),
50
+ children,
51
+ children.length === 0
52
+ )
53
+
54
+ const resourceDefinitionAttr = () =>
55
+ types.jsxAttribute(
56
+ types.jsxIdentifier('resourceDefinition'),
57
+ types.jsxExpressionContainer(
58
+ types.objectExpression([
59
+ types.objectProperty(types.stringLiteral('dataSourceId'), types.stringLiteral(DS_ID)),
60
+ types.objectProperty(types.stringLiteral('tableName'), types.stringLiteral(TABLE)),
61
+ types.objectProperty(
62
+ types.stringLiteral('dataSourceType'),
63
+ types.stringLiteral('teleport')
64
+ ),
65
+ ])
66
+ )
67
+ )
68
+
69
+ /** `<DataProvider name={'items'} resourceDefinition={…} renderSuccess={(items) => <Repeater …/>} />` */
70
+ const makeListProvider = (): types.JSXElement => {
71
+ const repeater = jsx('Repeater', [
72
+ types.jsxAttribute(
73
+ types.jsxIdentifier('items'),
74
+ types.jsxExpressionContainer(types.identifier('items'))
75
+ ),
76
+ types.jsxAttribute(
77
+ types.jsxIdentifier('renderItem'),
78
+ types.jsxExpressionContainer(
79
+ types.arrowFunctionExpression([types.identifier('product')], jsx('div', []))
80
+ )
81
+ ),
82
+ ])
83
+ return jsx('DataProvider', [
84
+ types.jsxAttribute(
85
+ types.jsxIdentifier('name'),
86
+ types.jsxExpressionContainer(types.stringLiteral('items'))
87
+ ),
88
+ resourceDefinitionAttr(),
89
+ types.jsxAttribute(
90
+ types.jsxIdentifier('renderSuccess'),
91
+ types.jsxExpressionContainer(
92
+ types.arrowFunctionExpression([types.identifier('items')], repeater)
93
+ )
94
+ ),
95
+ ])
96
+ }
97
+
98
+ /** `<DataProvider name={'item'} resourceDefinition={…} renderSuccess={(item) => <div/>} />` */
99
+ const makeItemProvider = (): types.JSXElement =>
100
+ jsx('DataProvider', [
101
+ types.jsxAttribute(
102
+ types.jsxIdentifier('name'),
103
+ types.jsxExpressionContainer(types.stringLiteral('item'))
104
+ ),
105
+ resourceDefinitionAttr(),
106
+ types.jsxAttribute(
107
+ types.jsxIdentifier('renderSuccess'),
108
+ types.jsxExpressionContainer(
109
+ types.arrowFunctionExpression([types.identifier('item')], jsx('div', []))
110
+ )
111
+ ),
112
+ ])
113
+
114
+ const makeComponentChunk = (provider: types.JSXElement): ChunkDefinition => ({
115
+ name: 'jsx-component',
116
+ type: ChunkType.AST,
117
+ fileType: FileType.JS,
118
+ linkAfter: [],
119
+ content: types.variableDeclaration('const', [
120
+ types.variableDeclarator(
121
+ types.identifier('ProductsList'),
122
+ types.arrowFunctionExpression(
123
+ [types.identifier('props')],
124
+ types.blockStatement([types.returnStatement(jsx('div', [], [provider]))])
125
+ )
126
+ ),
127
+ ]),
128
+ meta: { nodesLookup: { provider } },
129
+ })
130
+
131
+ // tslint:disable-next-line:no-any
132
+ const makeListNode = (paginated: boolean): any => ({
133
+ type: 'data-source-list',
134
+ content: {
135
+ renderPropIdentifier: 'items',
136
+ resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
137
+ resource: {
138
+ id: 'TQ_products',
139
+ params: { queryColumns: { type: 'static', content: ['name', 'es_name'] } },
140
+ },
141
+ nodes: {
142
+ success: {
143
+ type: 'cms-list-repeater',
144
+ content: {
145
+ renderPropIdentifier: 'product',
146
+ paginated,
147
+ perPage: 20,
148
+ searchEnabled: false,
149
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
150
+ },
151
+ },
152
+ },
153
+ },
154
+ })
155
+
156
+ /** An item whose params cannot be resolved at build time, so it fetches client-side. */
157
+ // tslint:disable-next-line:no-any
158
+ const makeDynamicItemNode = (withParams: boolean): any => ({
159
+ type: 'data-source-item',
160
+ content: {
161
+ renderPropIdentifier: 'item',
162
+ resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
163
+ resource: {
164
+ params: withParams
165
+ ? { limit: { type: 'dynamic', content: { referenceType: 'prop', id: 'limit' } } }
166
+ : {},
167
+ },
168
+ children: [{ type: 'element', content: { elementType: 'div' } }],
169
+ },
170
+ })
171
+
172
+ const run = async (
173
+ plugin: ReturnType<typeof createNextPagesDataSourcePlugin>,
174
+ // tslint:disable-next-line:no-any
175
+ node: any,
176
+ provider: types.JSXElement,
177
+ localized: boolean
178
+ ) => {
179
+ const chunk = makeComponentChunk(provider)
180
+ const dependencies: Record<string, UIDLDependency> = {}
181
+ // tslint:disable-next-line:no-any
182
+ const extractedResources: Record<string, any> = {}
183
+ const structure: ComponentStructure = {
184
+ uidl: { name: 'ProductsList', node, outputOptions: { folderPath: [] } },
185
+ chunks: [chunk],
186
+ dependencies,
187
+ options: {
188
+ dataSources: { [DS_ID]: dataSource },
189
+ extractedResources,
190
+ ...(localized ? { internationalization: I18N } : {}),
191
+ },
192
+ } as never
193
+ await plugin(structure)
194
+ const getStaticProps = structure.chunks.find((c) => c.name === 'getStaticProps')
195
+ return {
196
+ code: generator(chunk.content as types.Node).code,
197
+ staticProps: getStaticProps ? generator(getStaticProps.content as types.Node).code : '',
198
+ dependencies,
199
+ fetcher: FETCHER_KEYS.map((key) => extractedResources[key]?.content).find(Boolean) as string,
200
+ }
201
+ }
202
+
203
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
204
+
205
+ describe('data-source fetches in a multi-language project', () => {
206
+ const pagesPlugin = createNextPagesDataSourcePlugin()
207
+ const componentPlugin = createNextComponentDataSourcePlugin()
208
+
209
+ describe('a paginated list page', () => {
210
+ it('fetches the first page for the language getStaticProps runs for', async () => {
211
+ const { staticProps } = await run(pagesPlugin, makeListNode(true), makeListProvider(), true)
212
+ expect(collapse(staticProps)).toContain(
213
+ 'fetchData({ page: 1, perPage: 20, queryColumns: JSON.stringify(["name", "es_name"]), locale: context.locale })'
214
+ )
215
+ // The count is the same in every language.
216
+ expect(collapse(staticProps)).toContain('fetchCount().catch')
217
+ })
218
+
219
+ it('sends the client locale with every refetch and refetches when it changes', async () => {
220
+ const { code, dependencies } = await run(
221
+ pagesPlugin,
222
+ makeListNode(true),
223
+ makeListProvider(),
224
+ true
225
+ )
226
+ const flat = collapse(code)
227
+ expect(flat).toContain(
228
+ 'params={useMemo(() => ({ page: ds_0_page, perPage: 20, locale: router?.locale }), [ds_0_page, router?.locale])}'
229
+ )
230
+ expect(code).toContain('const router = useRouter()')
231
+ expect(dependencies.useRouter).toMatchObject({ type: 'library', path: 'next/router' })
232
+ })
233
+
234
+ it('bakes the per-language columns into the fetcher it emits', async () => {
235
+ const { fetcher } = await run(pagesPlugin, makeListNode(true), makeListProvider(), true)
236
+ expect(fetcher).toContain('var LOCALIZED_COLUMNS = {"name":{"es":"es_name"}}')
237
+ expect(fetcher).toContain('mainLanguage: "en"')
238
+ })
239
+ })
240
+
241
+ describe('a plain list page', () => {
242
+ it('prefetches and refetches in the page language', async () => {
243
+ const { code, staticProps } = await run(
244
+ pagesPlugin,
245
+ makeListNode(false),
246
+ makeListProvider(),
247
+ true
248
+ )
249
+ expect(collapse(staticProps)).toContain(
250
+ 'fetchData({ "queryColumns": JSON.stringify(["name", "es_name"]), locale: context.locale })'
251
+ )
252
+ expect(collapse(code)).toContain(
253
+ 'params={useMemo(() => ({ locale: router?.locale }), [router?.locale])}'
254
+ )
255
+ expect(code).toContain('const router = useRouter()')
256
+ })
257
+ })
258
+
259
+ describe('a component item that fetches client-side', () => {
260
+ it('adds the locale to the params it already sends', async () => {
261
+ const { code, dependencies } = await run(
262
+ componentPlugin,
263
+ makeDynamicItemNode(true),
264
+ makeItemProvider(),
265
+ true
266
+ )
267
+ const flat = collapse(code)
268
+ expect(flat).toContain('locale: router?.locale')
269
+ // The route is a template literal that interpolates the params.
270
+ expect(flat).toContain(
271
+ 'fetch(`/api/teleport-teleport_products-ds1?$' + '{new URLSearchParams(params)}`'
272
+ )
273
+ expect(code).toContain('const router = useRouter()')
274
+ expect(dependencies.useRouter).toBeDefined()
275
+ })
276
+
277
+ it('creates the params when the node declared none', async () => {
278
+ const { code } = await run(
279
+ componentPlugin,
280
+ makeDynamicItemNode(false),
281
+ makeItemProvider(),
282
+ true
283
+ )
284
+ const flat = collapse(code)
285
+ expect(flat).toContain('params={{ locale: router?.locale }}')
286
+ expect(flat).toContain('new URLSearchParams(params)')
287
+ })
288
+ })
289
+
290
+ describe('a single-language project', () => {
291
+ it.each([
292
+ ['a paginated list page', pagesPlugin, makeListNode(true), makeListProvider],
293
+ ['a plain list page', pagesPlugin, makeListNode(false), makeListProvider],
294
+ ['a component item', componentPlugin, makeDynamicItemNode(true), makeItemProvider],
295
+ ])(
296
+ 'generates %s exactly as before — no locale, no router',
297
+ async (_, plugin, node, provider) => {
298
+ const { code, staticProps, dependencies, fetcher } = await run(
299
+ plugin,
300
+ node,
301
+ provider(),
302
+ false
303
+ )
304
+ expect(code).not.toContain('locale')
305
+ expect(code).not.toContain('useRouter')
306
+ expect(staticProps).not.toContain('locale')
307
+ expect(dependencies.useRouter).toBeUndefined()
308
+ expect(fetcher).toContain('var LOCALIZED_COLUMNS = {}')
309
+ expect(fetcher).toContain('mainLanguage: null')
310
+ }
311
+ )
312
+ })
313
+ })
@@ -0,0 +1,88 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import { parse } from '@babel/parser'
4
+ import {
5
+ ChunkType,
6
+ FileType,
7
+ ComponentStructure,
8
+ ChunkDefinition,
9
+ } from '@teleporthq/teleport-types'
10
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
11
+
12
+ /**
13
+ * A paginated list under a tab panel — `{activeTabIndex === 0 && (…)}` — is a
14
+ * DataProvider on the RIGHT of a logical expression. The plugin's JSX walkers
15
+ * visited children, bodies, arguments and ternary branches, never `left` /
16
+ * `right`, so the provider was never wired: no page params, no fetcher, and a
17
+ * shop whose grid sat under its first tab rendered nothing.
18
+ */
19
+ const makeComponentChunk = (): ChunkDefinition => {
20
+ const program = parse(
21
+ `const TestComponent = (props) => {
22
+ return (
23
+ <div>
24
+ {activeTabIndex === 0 && (
25
+ <div className="panel">
26
+ <DataProvider name={'items'} renderSuccess={(items) => (
27
+ <Repeater items={items || []} renderItem={(product) => <div>{product.name}</div>} />
28
+ )} />
29
+ </div>
30
+ )}
31
+ </div>
32
+ )
33
+ }`,
34
+ { sourceType: 'module', plugins: ['jsx'] }
35
+ )
36
+ return {
37
+ name: 'jsx-component',
38
+ type: ChunkType.AST,
39
+ fileType: FileType.JS,
40
+ linkAfter: [],
41
+ content: program.program.body[0] as types.VariableDeclaration,
42
+ meta: {},
43
+ }
44
+ }
45
+
46
+ // tslint:disable-next-line:no-any
47
+ const makeUidlNode = (): any => ({
48
+ type: 'data-source-list',
49
+ content: {
50
+ renderPropIdentifier: 'items',
51
+ resourceDefinition: {
52
+ dataSourceId: 'ds1',
53
+ tableName: 'products',
54
+ dataSourceType: 'postgresql',
55
+ },
56
+ resource: { params: { queryColumns: { content: ['name'] } } },
57
+ nodes: {
58
+ success: {
59
+ type: 'cms-list-repeater',
60
+ content: {
61
+ renderPropIdentifier: 'product',
62
+ paginated: true,
63
+ perPage: 20,
64
+ searchEnabled: false,
65
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
66
+ },
67
+ },
68
+ },
69
+ },
70
+ })
71
+
72
+ describe('pagination plugin — a provider gated by a logical expression', () => {
73
+ it('wires the page params and the fetcher onto a DataProvider under `cond && (…)`', async () => {
74
+ const chunk = makeComponentChunk()
75
+ const structure: ComponentStructure = {
76
+ uidl: { name: 'TestComponent', node: makeUidlNode() },
77
+ chunks: [chunk],
78
+ dependencies: {},
79
+ options: { dataSources: {}, extractedResources: {} },
80
+ } as never
81
+ await createNextArrayMapperPaginationPlugin()(structure)
82
+ const code = generator(chunk.content as types.Node).code.replace(/\s+/g, ' ')
83
+
84
+ expect(code).toContain('page: ds_0_page')
85
+ expect(code).toContain('perPage: 20')
86
+ expect(code).toContain('fetchData={')
87
+ })
88
+ })
@@ -0,0 +1,217 @@
1
+ import { parse } from '@babel/parser'
2
+ import {
3
+ collectLocalizedColumns,
4
+ generateLocalizedColumnsHelper,
5
+ type LocalizedColumnMap,
6
+ } from '../src/localized-columns'
7
+ import { resolveContentLocalization, isLocalizedProject } from '../src/content-localization'
8
+ import { buildProductTransformOptions, getTransformWrapperCode } from '../src/transformations'
9
+ import { generateTeleportFetcher } from '../src/fetchers/teleport'
10
+
11
+ /**
12
+ * A Spanish listing links to `es_slug` and sorts by `es_name`, so the SQL a
13
+ * fetch runs has to know about the per-language columns — otherwise the
14
+ * details page 404s on the very slug the card linked to. The helper is pinned
15
+ * here by RUNNING the emitted JavaScript, the same way the runtime does.
16
+ */
17
+
18
+ const LOCALIZATION = { mainLocale: 'en', secondaryLocales: ['es', 'fr'] }
19
+
20
+ const PRODUCT_COLUMNS = [
21
+ 'id',
22
+ 'name',
23
+ 'slug',
24
+ 'description',
25
+ 'price',
26
+ 'og_title',
27
+ 'es_name',
28
+ 'es_slug',
29
+ 'es_description',
30
+ 'fr_name',
31
+ ]
32
+
33
+ interface Helper {
34
+ localizedEqualityClause: (
35
+ field: string,
36
+ operand: string,
37
+ value: unknown,
38
+ pushParam: (param: unknown) => string
39
+ ) => string | null
40
+ localizedSortFieldSql: (field: string, locale: unknown) => string | null
41
+ }
42
+
43
+ const loadHelper = (families: LocalizedColumnMap): Helper => {
44
+ // tslint:disable-next-line:function-constructor
45
+ const factory = new Function(
46
+ `${generateLocalizedColumnsHelper(
47
+ families
48
+ )}\nreturn { localizedEqualityClause, localizedSortFieldSql }`
49
+ )
50
+ return factory()
51
+ }
52
+
53
+ const bindParams = () => {
54
+ const params: unknown[] = []
55
+ const pushParam = (param: unknown) => {
56
+ params.push(param)
57
+ return '$' + params.length
58
+ }
59
+ return { params, pushParam }
60
+ }
61
+
62
+ describe('resolveContentLocalization', () => {
63
+ it('splits the project languages into the main locale and the copies', () => {
64
+ expect(
65
+ resolveContentLocalization({
66
+ internationalization: {
67
+ main: { name: 'English', locale: 'en' },
68
+ languages: { en: 'English', es: 'Spanish', fr: 'French' },
69
+ },
70
+ })
71
+ ).toEqual({ mainLocale: 'en', secondaryLocales: ['es', 'fr'] })
72
+ })
73
+
74
+ it('is absent for a single-language project', () => {
75
+ expect(resolveContentLocalization({})).toBeUndefined()
76
+ expect(isLocalizedProject({})).toBe(false)
77
+ })
78
+
79
+ it('reaches the fetcher through the shared transform options', () => {
80
+ const options = buildProductTransformOptions({
81
+ internationalization: {
82
+ main: { name: 'English', locale: 'en' },
83
+ languages: { en: 'English', es: 'Spanish' },
84
+ },
85
+ })
86
+ expect(options.localization).toEqual({ mainLocale: 'en', secondaryLocales: ['es'] })
87
+ expect(buildProductTransformOptions({}).localization).toBeUndefined()
88
+ })
89
+ })
90
+
91
+ describe('collectLocalizedColumns', () => {
92
+ it('pairs a column with the copies the schema actually holds', () => {
93
+ expect(collectLocalizedColumns(PRODUCT_COLUMNS, LOCALIZATION)).toEqual({
94
+ name: { es: 'es_name', fr: 'fr_name' },
95
+ slug: { es: 'es_slug' },
96
+ description: { es: 'es_description' },
97
+ })
98
+ })
99
+
100
+ it('never mistakes a prefix for a language', () => {
101
+ // `og_title` is Open Graph, and `og` is not a project language.
102
+ const families = collectLocalizedColumns(['title', 'og_title'], LOCALIZATION)
103
+ expect(families).toEqual({})
104
+ })
105
+
106
+ it('has nothing to say for a single-language project', () => {
107
+ expect(collectLocalizedColumns(PRODUCT_COLUMNS, undefined)).toEqual({})
108
+ expect(
109
+ collectLocalizedColumns(PRODUCT_COLUMNS, { mainLocale: 'en', secondaryLocales: [] })
110
+ ).toEqual({})
111
+ })
112
+
113
+ it('refuses a column it could not quote', () => {
114
+ const families = collectLocalizedColumns(['slug', 'es_slug', 'pt-BR_slug'], {
115
+ mainLocale: 'en',
116
+ secondaryLocales: ['es', 'pt-BR'],
117
+ })
118
+ expect(families).toEqual({ slug: { es: 'es_slug' } })
119
+ })
120
+ })
121
+
122
+ describe('generateLocalizedColumnsHelper', () => {
123
+ const families = collectLocalizedColumns(PRODUCT_COLUMNS, LOCALIZATION)
124
+ const helper = loadHelper(families)
125
+
126
+ it('emits plain source the fetcher template can embed', () => {
127
+ const source = generateLocalizedColumnsHelper(families)
128
+ expect(source).not.toContain('`')
129
+ expect(source).not.toContain('${')
130
+ expect(() => parse(source, { sourceType: 'script' })).not.toThrow()
131
+ })
132
+
133
+ it('matches equality on a translatable column in every language, binding once', () => {
134
+ const { params, pushParam } = bindParams()
135
+ const clause = helper.localizedEqualityClause('slug', '=', 'bolso-tote', pushParam)
136
+ expect(clause).toBe('("slug" = $1 OR "es_slug" = $1)')
137
+ expect(params).toEqual(['bolso-tote'])
138
+ })
139
+
140
+ it('answers an `in` set (already normalised to an array) the same way', () => {
141
+ const { params, pushParam } = bindParams()
142
+ const clause = helper.localizedEqualityClause('name', '=', ['A', 'B'], pushParam)
143
+ expect(clause).toBe(
144
+ '("name"::text = ANY($1::text[]) OR "es_name"::text = ANY($1::text[]) OR "fr_name"::text = ANY($1::text[]))'
145
+ )
146
+ expect(params).toEqual([['A', 'B']])
147
+ })
148
+
149
+ it('leaves every other column and operand to the ordinary branches', () => {
150
+ const { params, pushParam } = bindParams()
151
+ expect(helper.localizedEqualityClause('price', '=', '10', pushParam)).toBeNull()
152
+ expect(helper.localizedEqualityClause('slug', '!=', 'x', pushParam)).toBeNull()
153
+ expect(helper.localizedEqualityClause('slug', '=', null, pushParam)).toBeNull()
154
+ // An empty set means "no filter", as it does in the ordinary array branch.
155
+ expect(helper.localizedEqualityClause('slug', '=', [], pushParam)).toBeNull()
156
+ expect(params).toEqual([])
157
+ })
158
+
159
+ it("orders by the request's language, falling back to the base column", () => {
160
+ expect(helper.localizedSortFieldSql('name', 'es')).toBe(
161
+ `COALESCE(NULLIF("es_name", ''), "name")`
162
+ )
163
+ // The main language and a language without a copy order by the column itself.
164
+ expect(helper.localizedSortFieldSql('name', 'en')).toBeNull()
165
+ expect(helper.localizedSortFieldSql('slug', 'fr')).toBeNull()
166
+ expect(helper.localizedSortFieldSql('price', 'es')).toBeNull()
167
+ expect(helper.localizedSortFieldSql('name', null)).toBeNull()
168
+ })
169
+
170
+ it('is inert without families', () => {
171
+ const inert = loadHelper({})
172
+ const { pushParam } = bindParams()
173
+ expect(inert.localizedEqualityClause('slug', '=', 'x', pushParam)).toBeNull()
174
+ expect(inert.localizedSortFieldSql('name', 'es')).toBeNull()
175
+ })
176
+ })
177
+
178
+ describe('teleport fetcher — localized columns', () => {
179
+ const config = {
180
+ selectedTables: {
181
+ teleport_products: { columns: PRODUCT_COLUMNS.map((name) => ({ name })) },
182
+ },
183
+ }
184
+
185
+ it('bakes the column map from the table schema and the project languages', () => {
186
+ const route = generateTeleportFetcher(config, 'teleport_products', {
187
+ localization: LOCALIZATION,
188
+ })
189
+ expect(route).toContain(
190
+ 'var LOCALIZED_COLUMNS = {"name":{"es":"es_name","fr":"fr_name"},"slug":{"es":"es_slug"},"description":{"es":"es_description"}}'
191
+ )
192
+ expect(route).toContain('const localizedClause = localizedEqualityClause(field, operand, value')
193
+ expect(route).toContain(
194
+ "const requestLocale = typeof req.query.locale === 'string' ? req.query.locale : null"
195
+ )
196
+ expect(route).toContain(
197
+ 'localizedSortFieldSql(sort.field, requestLocale) || sortFieldSql(sort.field)'
198
+ )
199
+ expect(route).toContain('localizedSortFieldSql(sortBy, requestLocale) || sortBy')
200
+ })
201
+
202
+ it('bakes an empty map for a single-language project', () => {
203
+ const route = generateTeleportFetcher(config, 'teleport_products')
204
+ expect(route).toContain('var LOCALIZED_COLUMNS = {}')
205
+ })
206
+
207
+ it('resolves the row transform against the request locale and the baked main locale', () => {
208
+ const wrapper = getTransformWrapperCode('teleport_products', { localization: LOCALIZATION })
209
+ expect(wrapper).toContain(
210
+ "var currentLanguage = reqQuery && typeof reqQuery.locale === 'string' && reqQuery.locale ? reqQuery.locale : null"
211
+ )
212
+ expect(wrapper).toContain('mainLanguage: "en"')
213
+ expect(wrapper).not.toContain('reqQuery.lang')
214
+
215
+ expect(getTransformWrapperCode('teleport_products')).toContain('mainLanguage: null')
216
+ })
217
+ })
@@ -89,6 +89,21 @@ describe('pagination plugin — numbered mode', () => {
89
89
  expect(code).toContain('setDs_0_maxPages(data.count === 0 ? 0 : Math.ceil(data.count / 20))')
90
90
  })
91
91
 
92
+ it('drops a count response a newer count request has overtaken', async () => {
93
+ // Two filter changes in quick succession start two counts; the older,
94
+ // slower one landing last must not paint a strip for rows no longer shown.
95
+ const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
96
+ const flat = collapse(code)
97
+
98
+ expect(flat).toContain('const ds_0_countSeq = useRef(0)')
99
+ expect(flat).toContain('const __tqCountSeq = ++ds_0_countSeq.current')
100
+ expect(flat).toContain('if (__tqCountSeq !== ds_0_countSeq.current) return;')
101
+ // The guard sits before the setter, inside the response handler.
102
+ expect(flat.indexOf('if (__tqCountSeq !== ds_0_countSeq.current) return;')).toBeLessThan(
103
+ flat.indexOf('setDs_0_maxPages(data.count === 0')
104
+ )
105
+ })
106
+
92
107
  it('removes a numbered strip left over from a mode switch', async () => {
93
108
  // The builder never deletes controls on a mode switch (that would discard
94
109
  // the author's styling), so a Previous/Next list can still carry them.