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

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 (143) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +8 -1
  2. package/__tests__/blog-adjacent-arranged.test.ts +176 -0
  3. package/__tests__/blog-post-contents.test.ts +212 -0
  4. package/__tests__/blog-post-page-enrichment.test.ts +28 -5
  5. package/__tests__/blog-post-picture-stand-in.test.ts +48 -0
  6. package/__tests__/blog-post-reading-time.test.ts +55 -0
  7. package/__tests__/browser-row-policy.test.ts +5 -1
  8. package/__tests__/content-preset-wiring.test.ts +108 -0
  9. package/__tests__/fixed-list-sort.test.ts +183 -0
  10. package/__tests__/pagination-filter-groups.test.ts +186 -0
  11. package/__tests__/plain-list-sort.test.ts +215 -0
  12. package/__tests__/product-price-sort.ts +47 -19
  13. package/__tests__/rest-api-key-header.test.ts +18 -0
  14. package/__tests__/search-url-sync.test.ts +34 -15
  15. package/__tests__/shared-resource-prefetch.test.ts +65 -0
  16. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  17. package/dist/cjs/fetchers/postgresql.js +2 -1
  18. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  19. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/rest-api.js +3 -1
  21. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  22. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/teleport.js +3 -2
  24. package/dist/cjs/fetchers/teleport.js.map +1 -1
  25. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
  26. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  27. package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
  28. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  29. package/dist/cjs/filter-utils.d.ts +19 -7
  30. package/dist/cjs/filter-utils.d.ts.map +1 -1
  31. package/dist/cjs/filter-utils.js +49 -13
  32. package/dist/cjs/filter-utils.js.map +1 -1
  33. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  34. package/dist/cjs/pagination-plugin.js +137 -118
  35. package/dist/cjs/pagination-plugin.js.map +1 -1
  36. package/dist/cjs/product-price-sort.d.ts +0 -9
  37. package/dist/cjs/product-price-sort.d.ts.map +1 -1
  38. package/dist/cjs/product-price-sort.js +1 -13
  39. package/dist/cjs/product-price-sort.js.map +1 -1
  40. package/dist/cjs/row-order.d.ts +19 -0
  41. package/dist/cjs/row-order.d.ts.map +1 -0
  42. package/dist/cjs/row-order.js +45 -0
  43. package/dist/cjs/row-order.js.map +1 -0
  44. package/dist/cjs/sort-utils.d.ts +10 -0
  45. package/dist/cjs/sort-utils.d.ts.map +1 -1
  46. package/dist/cjs/sort-utils.js +22 -1
  47. package/dist/cjs/sort-utils.js.map +1 -1
  48. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
  49. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
  50. package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
  51. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
  52. package/dist/cjs/transformations/blog-comments.d.ts +1 -1
  53. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
  54. package/dist/cjs/transformations/blog-comments.js +1 -1
  55. package/dist/cjs/transformations/blog-comments.js.map +1 -1
  56. package/dist/cjs/transformations/blog-post.d.ts +14 -0
  57. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  58. package/dist/cjs/transformations/blog-post.js +4 -1
  59. package/dist/cjs/transformations/blog-post.js.map +1 -1
  60. package/dist/cjs/transformations/heading-anchors.d.ts +32 -8
  61. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
  62. package/dist/cjs/transformations/heading-anchors.js +34 -10
  63. package/dist/cjs/transformations/heading-anchors.js.map +1 -1
  64. package/dist/cjs/transformations/index.d.ts +24 -12
  65. package/dist/cjs/transformations/index.d.ts.map +1 -1
  66. package/dist/cjs/transformations/index.js +59 -11
  67. package/dist/cjs/transformations/index.js.map +1 -1
  68. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  69. package/dist/cjs/utils.d.ts.map +1 -1
  70. package/dist/cjs/utils.js +52 -3
  71. package/dist/cjs/utils.js.map +1 -1
  72. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  73. package/dist/esm/fetchers/postgresql.js +3 -2
  74. package/dist/esm/fetchers/postgresql.js.map +1 -1
  75. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  76. package/dist/esm/fetchers/rest-api.js +3 -1
  77. package/dist/esm/fetchers/rest-api.js.map +1 -1
  78. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  79. package/dist/esm/fetchers/teleport.js +4 -3
  80. package/dist/esm/fetchers/teleport.js.map +1 -1
  81. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
  82. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  83. package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
  84. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  85. package/dist/esm/filter-utils.d.ts +19 -7
  86. package/dist/esm/filter-utils.d.ts.map +1 -1
  87. package/dist/esm/filter-utils.js +46 -12
  88. package/dist/esm/filter-utils.js.map +1 -1
  89. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  90. package/dist/esm/pagination-plugin.js +140 -121
  91. package/dist/esm/pagination-plugin.js.map +1 -1
  92. package/dist/esm/product-price-sort.d.ts +0 -9
  93. package/dist/esm/product-price-sort.d.ts.map +1 -1
  94. package/dist/esm/product-price-sort.js +0 -11
  95. package/dist/esm/product-price-sort.js.map +1 -1
  96. package/dist/esm/row-order.d.ts +19 -0
  97. package/dist/esm/row-order.d.ts.map +1 -0
  98. package/dist/esm/row-order.js +40 -0
  99. package/dist/esm/row-order.js.map +1 -0
  100. package/dist/esm/sort-utils.d.ts +10 -0
  101. package/dist/esm/sort-utils.d.ts.map +1 -1
  102. package/dist/esm/sort-utils.js +20 -0
  103. package/dist/esm/sort-utils.js.map +1 -1
  104. package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
  105. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
  106. package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
  107. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
  108. package/dist/esm/transformations/blog-comments.d.ts +1 -1
  109. package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
  110. package/dist/esm/transformations/blog-comments.js +1 -1
  111. package/dist/esm/transformations/blog-comments.js.map +1 -1
  112. package/dist/esm/transformations/blog-post.d.ts +14 -0
  113. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  114. package/dist/esm/transformations/blog-post.js +4 -1
  115. package/dist/esm/transformations/blog-post.js.map +1 -1
  116. package/dist/esm/transformations/heading-anchors.d.ts +32 -8
  117. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
  118. package/dist/esm/transformations/heading-anchors.js +33 -9
  119. package/dist/esm/transformations/heading-anchors.js.map +1 -1
  120. package/dist/esm/transformations/index.d.ts +24 -12
  121. package/dist/esm/transformations/index.d.ts.map +1 -1
  122. package/dist/esm/transformations/index.js +58 -11
  123. package/dist/esm/transformations/index.js.map +1 -1
  124. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  125. package/dist/esm/utils.d.ts.map +1 -1
  126. package/dist/esm/utils.js +52 -3
  127. package/dist/esm/utils.js.map +1 -1
  128. package/package.json +5 -5
  129. package/src/fetchers/postgresql.ts +11 -15
  130. package/src/fetchers/rest-api.ts +3 -1
  131. package/src/fetchers/teleport.ts +12 -16
  132. package/src/fetchers/utils/browser-row-policy.ts +10 -20
  133. package/src/filter-utils.ts +102 -28
  134. package/src/pagination-plugin.ts +211 -175
  135. package/src/product-price-sort.ts +0 -11
  136. package/src/row-order.ts +54 -0
  137. package/src/sort-utils.ts +25 -0
  138. package/src/transformations/blog-adjacent-posts.ts +89 -3
  139. package/src/transformations/blog-comments.ts +5 -5
  140. package/src/transformations/blog-post.ts +64 -13
  141. package/src/transformations/heading-anchors.ts +79 -16
  142. package/src/transformations/index.ts +80 -13
  143. package/src/utils.ts +74 -3
@@ -0,0 +1,108 @@
1
+ import { ContentTables, TableAccess } from '@teleporthq/teleport-shared'
2
+ import {
3
+ BROWSER_READABLE_TABLES,
4
+ BROWSER_ROW_POLICIES as DATA_API_ROW_POLICIES,
5
+ } from '../../teleport-plugin-next-workflows/src/data-api-route-generator'
6
+ import {
7
+ BROWSER_ROW_POLICIES,
8
+ isViewDependentTable,
9
+ } from '../src/fetchers/utils/browser-row-policy'
10
+ import {
11
+ buildProductTransformOptions,
12
+ contentSettingsFor,
13
+ detectTransformationType,
14
+ getTransformWrapperCode,
15
+ getTransformationCode,
16
+ } from '../src/transformations'
17
+ import { generateBlogContextFileContent } from '../../teleport-project-generator-next/src/blog/blog-context-generator'
18
+
19
+ /**
20
+ * Every content preset in the registry is wired through every generator that
21
+ * tells a content table apart from a custom one. A preset registered in
22
+ * teleport-shared but unknown to one of these would publish its drafts, or
23
+ * serve its rows raw — so each check runs per preset, and a new one fails
24
+ * here before it fails on a site.
25
+ */
26
+ describe('content preset wiring in the generators', () => {
27
+ it.each(ContentTables.CONTENT_TABLES.map((tables) => [tables.key, tables]))(
28
+ 'the "%s" preset is recognised everywhere',
29
+ (_key, tables: ContentTables.ContentTables) => {
30
+ // Visitors are served published rows only, through both route families.
31
+ expect(BROWSER_ROW_POLICIES[tables.posts]).toBe(
32
+ ContentTables.CONTENT_POSTS_VISITOR_ROW_POLICY
33
+ )
34
+ expect(DATA_API_ROW_POLICIES[tables.posts]).toBe(
35
+ ContentTables.CONTENT_POSTS_VISITOR_ROW_POLICY
36
+ )
37
+ expect(BROWSER_READABLE_TABLES).toContain(tables.posts)
38
+ expect(isViewDependentTable(tables.posts, TableAccess.PROTECTED_TABLES)).toBe(true)
39
+
40
+ // The rows go through the post view model, whose SQL names THIS preset's tables.
41
+ expect(detectTransformationType(tables.posts)).toBe('blog-post')
42
+ expect(detectTransformationType(`public.${tables.posts}`)).toBe('blog-post')
43
+ const code = getTransformationCode(tables.posts)
44
+ expect(code).toContain(`SELECT * FROM ${tables.posts} WHERE id = ANY($1)`)
45
+ expect(code).toContain(`SELECT p.* FROM ${tables.posts} p,`)
46
+ if (tables.comments) {
47
+ expect(code).toContain(`FROM ${tables.comments} c`)
48
+ // A browser never lists comments: the table carries the commenters' emails.
49
+ expect(TableAccess.CUSTOMER_RECORD_TABLES).toContain(tables.comments)
50
+ expect(BROWSER_READABLE_TABLES).not.toContain(tables.comments)
51
+ }
52
+ }
53
+ )
54
+
55
+ it('leaves a custom table that only sounds like posts alone', () => {
56
+ expect(detectTransformationType('blog_posts')).toBeNull()
57
+ expect(BROWSER_ROW_POLICIES.blog_posts).toBeUndefined()
58
+ expect(BROWSER_READABLE_TABLES).not.toContain('blog_posts')
59
+ })
60
+
61
+ it('bakes each preset\u2019s own settings into its transform, never another\u2019s', () => {
62
+ const blogTaxonomy = [{ id: 'b1', name: 'Blog cat', slug: 'blog-cat' }]
63
+ const helpTaxonomy = [{ id: 'h1', name: 'Help cat', slug: 'help-cat' }]
64
+ const options = buildProductTransformOptions({
65
+ blogSettings: { categories: blogTaxonomy as never, headingAnchors: false, comments: true },
66
+ helpCenterSettings: {
67
+ categories: helpTaxonomy as never,
68
+ headingAnchors: true,
69
+ comments: false,
70
+ },
71
+ })
72
+ expect(contentSettingsFor(options, 'teleport_blog_posts')).toEqual({
73
+ categories: blogTaxonomy,
74
+ headingAnchors: false,
75
+ comments: true,
76
+ })
77
+ expect(contentSettingsFor(options, 'teleport_help_articles')).toEqual({
78
+ categories: helpTaxonomy,
79
+ headingAnchors: true,
80
+ comments: false,
81
+ })
82
+ expect(contentSettingsFor(options, 'teleport_products')).toEqual({})
83
+ // The Blog's legacy fields still answer for a caller that set only them.
84
+ expect(contentSettingsFor({ blogComments: true }, 'teleport_blog_posts').comments).toBe(true)
85
+
86
+ // The article transform carries the Help Center's taxonomy and anchors, not the Blog's.
87
+ const helpCode = getTransformationCode('teleport_help_articles', options)
88
+ expect(helpCode).toContain('Help cat')
89
+ expect(helpCode).not.toContain('Blog cat')
90
+ expect(helpCode).toContain('var BLOG_HEADING_ANCHORS = true')
91
+ // Comments are a Blog feature: the article wrapper never queries a comments table.
92
+ expect(getTransformWrapperCode('teleport_help_articles', options)).not.toContain(
93
+ 'getBlogCommentsMap('
94
+ )
95
+ expect(getTransformWrapperCode('teleport_blog_posts', options)).toContain('getBlogCommentsMap(')
96
+ })
97
+
98
+ it('exposes both taxonomies from the one generated context module', () => {
99
+ const code = generateBlogContextFileContent(
100
+ { categories: [{ id: 'b1', name: 'Blog cat', slug: 'blog-cat' }] as never },
101
+ { categories: [{ id: 'h1', name: 'Help cat', slug: 'help-cat' }] as never }
102
+ )
103
+ expect(code).toContain('const BLOG_CATEGORIES = [{"id":"b1"')
104
+ expect(code).toContain('const HELP_CATEGORIES = [{"id":"h1"')
105
+ expect(code).toContain('export const useBlogCategories = ()')
106
+ expect(code).toContain('export const useHelpCategories = ()')
107
+ })
108
+ })
@@ -0,0 +1,183 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ UIDLDataCacheConfig,
9
+ } from '@teleporthq/teleport-types'
10
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
11
+ import { readFixedSort } from '../src/sort-utils'
12
+
13
+ /**
14
+ * A list whose sort the editor FIXED (a blog listing's newest first: a static
15
+ * column, a static direction) is a static sort. Read as dynamic, it made the
16
+ * list skip the server-side first page — `initialData` is left out for a
17
+ * dynamic sort, since that prefetch ran without one — so the pre-rendered
18
+ * HTML of every blog and help center listing carried no posts, and the
19
+ * browser fetched page 1 a second time. Only a sort bound to state is dynamic.
20
+ */
21
+
22
+ const CACHE: UIDLDataCacheConfig = {
23
+ enabled: true,
24
+ ttlSeconds: 60,
25
+ client: true,
26
+ server: true,
27
+ versionScope: 'ds1:posts',
28
+ }
29
+
30
+ const element = (name: string, attributes: types.JSXAttribute[] = []) =>
31
+ types.jsxElement(
32
+ types.jsxOpeningElement(types.jsxIdentifier(name), attributes, true),
33
+ null,
34
+ [],
35
+ true
36
+ )
37
+
38
+ const makeDataProviderJSX = (): types.JSXElement =>
39
+ element('DataProvider', [
40
+ types.jsxAttribute(
41
+ types.jsxIdentifier('name'),
42
+ types.jsxExpressionContainer(types.stringLiteral('items'))
43
+ ),
44
+ types.jsxAttribute(
45
+ types.jsxIdentifier('renderSuccess'),
46
+ types.jsxExpressionContainer(
47
+ types.arrowFunctionExpression(
48
+ [types.identifier('items')],
49
+ element('Repeater', [
50
+ types.jsxAttribute(
51
+ types.jsxIdentifier('renderItem'),
52
+ types.jsxExpressionContainer(
53
+ types.arrowFunctionExpression([types.identifier('post')], element('div'))
54
+ )
55
+ ),
56
+ ])
57
+ )
58
+ )
59
+ ),
60
+ ])
61
+
62
+ const makeComponentChunk = (): ChunkDefinition => ({
63
+ name: 'jsx-component',
64
+ type: ChunkType.AST,
65
+ fileType: FileType.JS,
66
+ linkAfter: [],
67
+ content: types.variableDeclaration('const', [
68
+ types.variableDeclarator(
69
+ types.identifier('PostsList'),
70
+ types.arrowFunctionExpression(
71
+ [types.identifier('props')],
72
+ types.blockStatement([types.returnStatement(makeDataProviderJSX())])
73
+ )
74
+ ),
75
+ ]),
76
+ meta: {},
77
+ })
78
+
79
+ type SortKind = 'fixed' | 'state' | 'none'
80
+
81
+ const SORTS: Record<SortKind, Record<string, unknown>> = {
82
+ fixed: {
83
+ sort: { type: 'static', content: 'created_at' },
84
+ sortDirection: { type: 'static', content: 'desc' },
85
+ },
86
+ state: {
87
+ sort: { type: 'expr', content: 'sortBy.split("-")[0]' },
88
+ sortDirection: { type: 'expr', content: 'sortBy.split("-")[1]' },
89
+ },
90
+ none: {},
91
+ }
92
+
93
+ const runPlugin = async (sort: SortKind, cache?: UIDLDataCacheConfig): Promise<string> => {
94
+ const chunk = makeComponentChunk()
95
+ const structure: ComponentStructure = {
96
+ uidl: {
97
+ name: 'PostsList',
98
+ node: {
99
+ type: 'data-source-list',
100
+ content: {
101
+ renderPropIdentifier: 'items',
102
+ resourceDefinition: {
103
+ dataSourceId: 'ds1',
104
+ tableName: 'posts',
105
+ dataSourceType: 'postgresql',
106
+ },
107
+ resource: { params: { queryColumns: { content: ['title'] } } },
108
+ nodes: {
109
+ success: {
110
+ type: 'cms-list-repeater',
111
+ content: {
112
+ renderPropIdentifier: 'post',
113
+ paginated: true,
114
+ perPage: 20,
115
+ searchEnabled: true,
116
+ searchDebounce: 300,
117
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
118
+ ...(cache ? { cache } : {}),
119
+ ...SORTS[sort],
120
+ },
121
+ },
122
+ },
123
+ },
124
+ },
125
+ },
126
+ chunks: [chunk],
127
+ dependencies: {},
128
+ options: {
129
+ dataSources: {
130
+ ds1: {
131
+ id: 'ds1',
132
+ name: 'Content',
133
+ type: 'postgresql',
134
+ config: { host: 'h', database: 'd', user: 'u', password: 'p' },
135
+ },
136
+ },
137
+ extractedResources: {},
138
+ },
139
+ } as never
140
+ await createNextArrayMapperPaginationPlugin()(structure)
141
+ return generator(chunk.content as types.Node).code.replace(/\s+/g, ' ')
142
+ }
143
+
144
+ describe('a list sort the editor fixed', () => {
145
+ it('keeps the server-side first page and asks for the sort itself', async () => {
146
+ const code = await runPlugin('fixed')
147
+ expect(code).toContain('initialData=')
148
+ expect(code).toContain('sorts: JSON.stringify([{ field: "created_at", order: "desc" }])')
149
+ })
150
+
151
+ it('may peek at the browser cache on mount, as an unsorted list may', async () => {
152
+ expect(await runPlugin('fixed', CACHE)).toContain('ds_0_cached')
153
+ expect(await runPlugin('none', CACHE)).toContain('ds_0_cached')
154
+ })
155
+
156
+ it('leaves a sort bound to state dynamic: no first page from the server, no peek', async () => {
157
+ const code = await runPlugin('state', CACHE)
158
+ expect(code).not.toContain('initialData=')
159
+ expect(code).not.toContain('ds_0_cached')
160
+ expect(code).toContain('field: sortBy.split("-")[0]')
161
+ })
162
+ })
163
+
164
+ describe('readFixedSort', () => {
165
+ it('reads a static column and direction, ascending by default', () => {
166
+ expect(
167
+ readFixedSort({ type: 'static', content: 'created_at' }, { type: 'static', content: 'desc' })
168
+ ).toEqual({ field: 'created_at', order: 'desc' })
169
+ expect(readFixedSort({ type: 'static', content: 'title' }, undefined)).toEqual({
170
+ field: 'title',
171
+ order: 'asc',
172
+ })
173
+ })
174
+
175
+ it('refuses anything bound to state, and an empty column', () => {
176
+ expect(readFixedSort({ type: 'expr', content: 'sortBy' }, undefined)).toBeUndefined()
177
+ expect(
178
+ readFixedSort({ type: 'static', content: 'title' }, { type: 'expr', content: 'direction' })
179
+ ).toBeUndefined()
180
+ expect(readFixedSort({ type: 'static', content: ' ' }, undefined)).toBeUndefined()
181
+ expect(readFixedSort(undefined, undefined)).toBeUndefined()
182
+ })
183
+ })
@@ -0,0 +1,186 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkDefinition,
5
+ ChunkType,
6
+ ComponentStructure,
7
+ FileType,
8
+ } from '@teleporthq/teleport-types'
9
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
10
+ import {
11
+ makeUidlNode,
12
+ makeWidgetComponentChunk,
13
+ type RepeaterOptions,
14
+ } from './_helpers/pagination-widget-fixture'
15
+
16
+ /**
17
+ * A list filter can say "Category is Commercial OR Narrative" — an "any of"
18
+ * group. The paginated and searchable lists used to flatten that group into a
19
+ * flat list the fetcher joined with AND, so the published site showed the rows
20
+ * that were BOTH Commercial and Narrative: none. The group now reaches the
21
+ * fetcher as a group, in the browser fetch and in the build-time prefetch
22
+ * alike, and the fetcher's own `normalizeFilterTree` honours its operator
23
+ * (`filter-groups.test.ts`).
24
+ */
25
+
26
+ const condition = (source: string, destination: unknown, operand = '=') => ({
27
+ type: 'condition',
28
+ source,
29
+ destination,
30
+ operand,
31
+ })
32
+
33
+ const group = (operator: 'and' | 'or', children: unknown[]) => ({
34
+ type: 'group',
35
+ operator,
36
+ children,
37
+ })
38
+
39
+ const stateRef = (id: string) => ({ type: 'dynamic', content: { referenceType: 'state', id } })
40
+
41
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
42
+
43
+ // The page's `getStaticProps` as the main data-source plugin leaves it: a
44
+ // `Promise.all` of prefetches and a `props` object, which the pagination plugin
45
+ // extends with the paginated list's first page and its count.
46
+ const makeGetStaticPropsChunk = (): ChunkDefinition => ({
47
+ name: 'getStaticProps',
48
+ type: ChunkType.AST,
49
+ fileType: FileType.JS,
50
+ linkAfter: ['jsx-component'],
51
+ meta: {},
52
+ content: types.exportNamedDeclaration(
53
+ types.functionDeclaration(
54
+ types.identifier('getStaticProps'),
55
+ [types.identifier('context')],
56
+ types.blockStatement([
57
+ types.tryStatement(
58
+ types.blockStatement([
59
+ types.variableDeclaration('const', [
60
+ types.variableDeclarator(
61
+ types.arrayPattern([]),
62
+ types.awaitExpression(
63
+ types.callExpression(
64
+ types.memberExpression(types.identifier('Promise'), types.identifier('all')),
65
+ [types.arrayExpression([])]
66
+ )
67
+ )
68
+ ),
69
+ ]),
70
+ types.returnStatement(
71
+ types.objectExpression([
72
+ types.objectProperty(types.identifier('props'), types.objectExpression([])),
73
+ ])
74
+ ),
75
+ ]),
76
+ types.catchClause(
77
+ types.identifier('error'),
78
+ types.blockStatement([
79
+ types.returnStatement(
80
+ types.objectExpression([
81
+ types.objectProperty(types.identifier('props'), types.objectExpression([])),
82
+ ])
83
+ ),
84
+ ])
85
+ )
86
+ ),
87
+ ]),
88
+ false,
89
+ true
90
+ )
91
+ ),
92
+ })
93
+
94
+ const runPlugin = async (repeater: RepeaterOptions) => {
95
+ const chunk = makeWidgetComponentChunk({ controls: ['previous', 'next'] })
96
+ const staticProps = makeGetStaticPropsChunk()
97
+ const structure: ComponentStructure = {
98
+ uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
99
+ chunks: [chunk, staticProps],
100
+ dependencies: {},
101
+ options: { dataSources: {}, extractedResources: {} },
102
+ } as never
103
+ await createNextArrayMapperPaginationPlugin()(structure)
104
+ return {
105
+ component: collapse(generator(chunk.content as types.Node).code),
106
+ staticProps: collapse(generator(staticProps.content as types.Node).code),
107
+ }
108
+ }
109
+
110
+ const OR_GROUP = [
111
+ group('or', [condition('Category', 'Commercial'), condition('Category', 'Narrative')]),
112
+ ]
113
+
114
+ const expectOrGroup = (code: string) => {
115
+ expect(code).toContain('type: "group"')
116
+ expect(code).toContain('operator: "or"')
117
+ expect(code).toContain('source: "Category", destination: "Commercial", operand: "="')
118
+ expect(code).toContain('source: "Category", destination: "Narrative", operand: "="')
119
+ // The two conditions sit INSIDE the group's children, never beside it.
120
+ expect(code).toMatch(/children: \[[^\]]*Commercial[^\]]*Narrative[^\]]*\]/)
121
+ }
122
+
123
+ describe('pagination plugin — filter groups', () => {
124
+ it('a paginated list keeps an or-group as an or-group in its browser fetch', async () => {
125
+ const { component } = await runPlugin({ paginated: true, filters: OR_GROUP })
126
+ expectOrGroup(component)
127
+ })
128
+
129
+ it('a searchable list keeps it too', async () => {
130
+ const { component } = await runPlugin({
131
+ paginated: true,
132
+ searchEnabled: true,
133
+ filters: OR_GROUP,
134
+ })
135
+ expectOrGroup(component)
136
+ })
137
+
138
+ it('the build-time prefetch and its count carry the same group', async () => {
139
+ const { staticProps } = await runPlugin({ paginated: true, filters: OR_GROUP })
140
+ expect(staticProps).toContain('page: 1')
141
+ expectOrGroup(staticProps)
142
+ // Once for the first page, once for the count.
143
+ expect(staticProps.match(/operator: "or"/g)).toHaveLength(2)
144
+ })
145
+
146
+ it('the runtime drop of empty destinations lets a group through and prunes inside it', async () => {
147
+ const { component } = await runPlugin({
148
+ paginated: true,
149
+ filters: [
150
+ group('or', [
151
+ condition('Category', 'Commercial'),
152
+ condition('Category', stateRef('picked')),
153
+ ]),
154
+ ],
155
+ })
156
+ // Every level is filtered; the predicate keeps groups themselves.
157
+ expect(component).toContain('__f.type === "group" ||')
158
+ expect(component).toMatch(/children: \[.*\]\.filter\(__f =>/)
159
+ // The state-bound leaf is still a dependency of the fetch params.
160
+ expect(component).toContain('picked')
161
+ })
162
+
163
+ it('the prefetch prunes a dynamic leaf but keeps the group around its static sibling', async () => {
164
+ const { staticProps } = await runPlugin({
165
+ paginated: true,
166
+ filters: [
167
+ group('or', [
168
+ condition('Category', 'Commercial'),
169
+ condition('Category', stateRef('picked')),
170
+ ]),
171
+ ],
172
+ })
173
+ expect(staticProps).toContain('operator: "or"')
174
+ expect(staticProps).toContain('destination: "Commercial"')
175
+ expect(staticProps).not.toContain('picked')
176
+ })
177
+
178
+ it('a legacy flat condition list still works', async () => {
179
+ const { component } = await runPlugin({
180
+ paginated: true,
181
+ filters: [{ source: 'Category', destination: 'Commercial', operand: '=' }],
182
+ })
183
+ expect(component).toContain('source: "Category", destination: "Commercial", operand: "="')
184
+ expect(component).not.toContain('type: "group"')
185
+ })
186
+ })
@@ -0,0 +1,215 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ } from '@teleporthq/teleport-types'
9
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
10
+
11
+ /**
12
+ * A PLAIN list — no pagination, no search — fetches with the sort its
13
+ * repeater carries, like every other list: the three paginated / searchable
14
+ * kinds rebuild their params with it, while a plain list's params were kept
15
+ * as they came and the sort never reached the fetch, so the rows arrived in
16
+ * the table's own order whatever the editor said.
17
+ */
18
+
19
+ const element = (name: string, attributes: types.JSXAttribute[] = []) =>
20
+ types.jsxElement(
21
+ types.jsxOpeningElement(types.jsxIdentifier(name), attributes, true),
22
+ null,
23
+ [],
24
+ true
25
+ )
26
+
27
+ const makeDataProviderJSX = (): types.JSXElement =>
28
+ element('DataProvider', [
29
+ types.jsxAttribute(
30
+ types.jsxIdentifier('name'),
31
+ types.jsxExpressionContainer(types.stringLiteral('items'))
32
+ ),
33
+ types.jsxAttribute(
34
+ types.jsxIdentifier('renderSuccess'),
35
+ types.jsxExpressionContainer(
36
+ types.arrowFunctionExpression(
37
+ [types.identifier('items')],
38
+ element('Repeater', [
39
+ types.jsxAttribute(
40
+ types.jsxIdentifier('renderItem'),
41
+ types.jsxExpressionContainer(
42
+ types.arrowFunctionExpression([types.identifier('post')], element('div'))
43
+ )
44
+ ),
45
+ ])
46
+ )
47
+ )
48
+ ),
49
+ ])
50
+
51
+ const makeComponentChunk = (): ChunkDefinition => ({
52
+ name: 'jsx-component',
53
+ type: ChunkType.AST,
54
+ fileType: FileType.JS,
55
+ linkAfter: [],
56
+ content: types.variableDeclaration('const', [
57
+ types.variableDeclarator(
58
+ types.identifier('PostsList'),
59
+ types.arrowFunctionExpression(
60
+ [types.identifier('props')],
61
+ types.blockStatement([types.returnStatement(makeDataProviderJSX())])
62
+ )
63
+ ),
64
+ ]),
65
+ meta: {},
66
+ })
67
+
68
+ type SortKind = 'fixed' | 'state' | 'none'
69
+
70
+ const SORTS: Record<SortKind, Record<string, unknown>> = {
71
+ fixed: {
72
+ sort: { type: 'static', content: 'title' },
73
+ sortDirection: { type: 'static', content: 'asc' },
74
+ },
75
+ state: {
76
+ sort: { type: 'expr', content: 'sortBy.split("-")[0]' },
77
+ sortDirection: { type: 'expr', content: 'sortBy.split("-")[1]' },
78
+ },
79
+ none: {},
80
+ }
81
+
82
+ const runPlugin = async (
83
+ sort: SortKind,
84
+ options: { legacySorts?: boolean; params?: boolean; prefetched?: boolean } = {}
85
+ ): Promise<string> => {
86
+ const chunk = makeComponentChunk()
87
+ if (options.prefetched) {
88
+ // A page's list, seeded with its server prefetch.
89
+ const provider = (
90
+ (chunk.content as types.VariableDeclaration).declarations[0]
91
+ .init as types.ArrowFunctionExpression
92
+ ).body as types.BlockStatement
93
+ const jsx = (provider.body[0] as types.ReturnStatement).argument as types.JSXElement
94
+ jsx.openingElement.attributes.push(
95
+ types.jsxAttribute(
96
+ types.jsxIdentifier('initialData'),
97
+ types.jsxExpressionContainer(
98
+ types.memberExpression(types.identifier('props'), types.identifier('items'))
99
+ )
100
+ )
101
+ )
102
+ }
103
+ if (options.params) {
104
+ // The params the data-source plugin writes for a plain list: its filters.
105
+ const provider = (
106
+ (chunk.content as types.VariableDeclaration).declarations[0]
107
+ .init as types.ArrowFunctionExpression
108
+ ).body as types.BlockStatement
109
+ const jsx = (provider.body[0] as types.ReturnStatement).argument as types.JSXElement
110
+ jsx.openingElement.attributes.push(
111
+ types.jsxAttribute(
112
+ types.jsxIdentifier('params'),
113
+ types.jsxExpressionContainer(
114
+ types.objectExpression([
115
+ types.objectProperty(types.identifier('filters'), types.stringLiteral('[]')),
116
+ ])
117
+ )
118
+ )
119
+ )
120
+ }
121
+ const structure: ComponentStructure = {
122
+ uidl: {
123
+ name: 'PostsList',
124
+ node: {
125
+ type: 'data-source-list',
126
+ content: {
127
+ renderPropIdentifier: 'items',
128
+ resourceDefinition: {
129
+ dataSourceId: 'ds1',
130
+ tableName: 'posts',
131
+ dataSourceType: 'postgresql',
132
+ },
133
+ resource: {
134
+ params: {
135
+ queryColumns: { content: ['title'] },
136
+ ...(options.legacySorts
137
+ ? { sorts: { content: [{ field: 'created_at', order: 'desc' }] } }
138
+ : {}),
139
+ },
140
+ },
141
+ nodes: {
142
+ success: {
143
+ type: 'cms-list-repeater',
144
+ content: {
145
+ renderPropIdentifier: 'post',
146
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
147
+ ...SORTS[sort],
148
+ },
149
+ },
150
+ },
151
+ },
152
+ },
153
+ },
154
+ chunks: [chunk],
155
+ dependencies: {},
156
+ options: {
157
+ dataSources: {
158
+ ds1: {
159
+ id: 'ds1',
160
+ name: 'Content',
161
+ type: 'postgresql',
162
+ config: { host: 'h', database: 'd', user: 'u', password: 'p' },
163
+ },
164
+ },
165
+ extractedResources: {},
166
+ },
167
+ } as never
168
+ await createNextArrayMapperPaginationPlugin()(structure)
169
+ return generator(chunk.content as types.Node).code
170
+ }
171
+
172
+ const squash = (code: string) => code.replace(/\s+/g, ' ')
173
+
174
+ describe('a plain list fetches with its repeater’s sort', () => {
175
+ it('sends a sort the editor fixed', async () => {
176
+ const code = squash(await runPlugin('fixed'))
177
+ expect(code).toContain(
178
+ 'params={useMemo(() => ({ sorts: JSON.stringify([{ field: "title", order: "asc" }]) }), [])}'
179
+ )
180
+ })
181
+
182
+ it('sends a sort bound to state, and refetches when the state changes', async () => {
183
+ const code = squash(await runPlugin('state'))
184
+ expect(code).toContain(
185
+ 'sorts: JSON.stringify([{ field: sortBy.split("-")[0], order: sortBy.split("-")[1] }])'
186
+ )
187
+ expect(code).toMatch(/\}\), \[sortBy\]\)\}/)
188
+ })
189
+
190
+ it('adds the sort beside the params the list already had', async () => {
191
+ const code = squash(await runPlugin('fixed', { params: true }))
192
+ expect(code).toContain(
193
+ 'params={useMemo(() => ({ filters: "[]", sorts: JSON.stringify([{ field: "title", order: "asc" }]) }), [])}'
194
+ )
195
+ })
196
+
197
+ it('keeps the resource’s own sorts first, as every other list does', async () => {
198
+ const code = squash(await runPlugin('fixed', { legacySorts: true }))
199
+ expect(code).toContain('sorts: JSON.stringify([{ field: "created_at", order: "desc" }])')
200
+ expect(code).not.toContain('field: "title"')
201
+ })
202
+
203
+ it('drops the prefetched rows for a sort bound to state, which the server cannot know', async () => {
204
+ expect(squash(await runPlugin('state', { prefetched: true }))).not.toContain('initialData')
205
+ expect(squash(await runPlugin('fixed', { prefetched: true }))).toContain(
206
+ 'initialData={props.items}'
207
+ )
208
+ })
209
+
210
+ it('leaves a list without a sort as it was', async () => {
211
+ const code = squash(await runPlugin('none'))
212
+ expect(code).not.toContain('sorts')
213
+ expect(code).not.toContain('params=')
214
+ })
215
+ })