@teleporthq/teleport-plugin-next-data-source 0.43.74 → 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 (231) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +95 -5
  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__/booking-item-transform.test.ts +120 -0
  8. package/__tests__/browser-row-policy.test.ts +13 -3
  9. package/__tests__/content-preset-wiring.test.ts +108 -0
  10. package/__tests__/fetchers.test.ts +1 -1
  11. package/__tests__/fixed-list-sort.test.ts +183 -0
  12. package/__tests__/infinite-scroll.test.ts +154 -5
  13. package/__tests__/list-sort-order.test.ts +61 -0
  14. package/__tests__/null-filter-where.test.ts +132 -0
  15. package/__tests__/pagination-filter-groups.test.ts +186 -0
  16. package/__tests__/pg-date-parser.test.ts +73 -0
  17. package/__tests__/plain-list-sort.test.ts +215 -0
  18. package/__tests__/product-price-sort.ts +58 -25
  19. package/__tests__/range-control.test.ts +159 -0
  20. package/__tests__/rest-api-key-header.test.ts +18 -0
  21. package/__tests__/search-input-marker.test.ts +86 -0
  22. package/__tests__/search-url-sync.test.ts +61 -16
  23. package/__tests__/shared-resource-prefetch.test.ts +65 -0
  24. package/__tests__/table-read-route-protected-tables.test.ts +33 -3
  25. package/dist/cjs/count-effect.d.ts +7 -1
  26. package/dist/cjs/count-effect.d.ts.map +1 -1
  27. package/dist/cjs/count-effect.js +15 -5
  28. package/dist/cjs/count-effect.js.map +1 -1
  29. package/dist/cjs/fetchers/pg-client-import.d.ts +12 -0
  30. package/dist/cjs/fetchers/pg-client-import.d.ts.map +1 -0
  31. package/dist/cjs/fetchers/pg-client-import.js +15 -0
  32. package/dist/cjs/fetchers/pg-client-import.js.map +1 -0
  33. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/postgresql.js +4 -1
  35. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  36. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/raw-query.js +2 -1
  38. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  39. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/rest-api.js +3 -1
  41. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  42. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/teleport.js +5 -2
  44. package/dist/cjs/fetchers/teleport.js.map +1 -1
  45. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
  46. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  47. package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
  48. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  49. package/dist/cjs/filter-utils.d.ts +19 -7
  50. package/dist/cjs/filter-utils.d.ts.map +1 -1
  51. package/dist/cjs/filter-utils.js +49 -13
  52. package/dist/cjs/filter-utils.js.map +1 -1
  53. package/dist/cjs/infinite-scroll.d.ts +25 -2
  54. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  55. package/dist/cjs/infinite-scroll.js +144 -33
  56. package/dist/cjs/infinite-scroll.js.map +1 -1
  57. package/dist/cjs/list-sort-order.d.ts +14 -0
  58. package/dist/cjs/list-sort-order.d.ts.map +1 -0
  59. package/dist/cjs/list-sort-order.js +36 -0
  60. package/dist/cjs/list-sort-order.js.map +1 -0
  61. package/dist/cjs/pagination-controls.d.ts +22 -3
  62. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  63. package/dist/cjs/pagination-controls.js +29 -3
  64. package/dist/cjs/pagination-controls.js.map +1 -1
  65. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  66. package/dist/cjs/pagination-plugin.js +267 -212
  67. package/dist/cjs/pagination-plugin.js.map +1 -1
  68. package/dist/cjs/product-price-sort.d.ts +0 -9
  69. package/dist/cjs/product-price-sort.d.ts.map +1 -1
  70. package/dist/cjs/product-price-sort.js +1 -13
  71. package/dist/cjs/product-price-sort.js.map +1 -1
  72. package/dist/cjs/range-control.d.ts +45 -0
  73. package/dist/cjs/range-control.d.ts.map +1 -0
  74. package/dist/cjs/range-control.js +165 -0
  75. package/dist/cjs/range-control.js.map +1 -0
  76. package/dist/cjs/row-order.d.ts +20 -0
  77. package/dist/cjs/row-order.d.ts.map +1 -0
  78. package/dist/cjs/row-order.js +46 -0
  79. package/dist/cjs/row-order.js.map +1 -0
  80. package/dist/cjs/search-input.d.ts +42 -0
  81. package/dist/cjs/search-input.d.ts.map +1 -0
  82. package/dist/cjs/search-input.js +158 -0
  83. package/dist/cjs/search-input.js.map +1 -0
  84. package/dist/cjs/sort-utils.d.ts +10 -0
  85. package/dist/cjs/sort-utils.d.ts.map +1 -1
  86. package/dist/cjs/sort-utils.js +22 -1
  87. package/dist/cjs/sort-utils.js.map +1 -1
  88. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
  89. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
  90. package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
  91. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
  92. package/dist/cjs/transformations/blog-comments.d.ts +1 -1
  93. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
  94. package/dist/cjs/transformations/blog-comments.js +1 -1
  95. package/dist/cjs/transformations/blog-comments.js.map +1 -1
  96. package/dist/cjs/transformations/blog-post.d.ts +14 -0
  97. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  98. package/dist/cjs/transformations/blog-post.js +4 -1
  99. package/dist/cjs/transformations/blog-post.js.map +1 -1
  100. package/dist/cjs/transformations/booking-item.d.ts +24 -0
  101. package/dist/cjs/transformations/booking-item.d.ts.map +1 -0
  102. package/dist/cjs/transformations/booking-item.js +27 -0
  103. package/dist/cjs/transformations/booking-item.js.map +1 -0
  104. package/dist/cjs/transformations/heading-anchors.d.ts +32 -8
  105. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
  106. package/dist/cjs/transformations/heading-anchors.js +34 -10
  107. package/dist/cjs/transformations/heading-anchors.js.map +1 -1
  108. package/dist/cjs/transformations/index.d.ts +27 -13
  109. package/dist/cjs/transformations/index.d.ts.map +1 -1
  110. package/dist/cjs/transformations/index.js +73 -16
  111. package/dist/cjs/transformations/index.js.map +1 -1
  112. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  113. package/dist/cjs/utils.d.ts.map +1 -1
  114. package/dist/cjs/utils.js +52 -3
  115. package/dist/cjs/utils.js.map +1 -1
  116. package/dist/esm/count-effect.d.ts +7 -1
  117. package/dist/esm/count-effect.d.ts.map +1 -1
  118. package/dist/esm/count-effect.js +15 -5
  119. package/dist/esm/count-effect.js.map +1 -1
  120. package/dist/esm/fetchers/pg-client-import.d.ts +12 -0
  121. package/dist/esm/fetchers/pg-client-import.d.ts.map +1 -0
  122. package/dist/esm/fetchers/pg-client-import.js +12 -0
  123. package/dist/esm/fetchers/pg-client-import.js.map +1 -0
  124. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  125. package/dist/esm/fetchers/postgresql.js +5 -2
  126. package/dist/esm/fetchers/postgresql.js.map +1 -1
  127. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  128. package/dist/esm/fetchers/raw-query.js +2 -1
  129. package/dist/esm/fetchers/raw-query.js.map +1 -1
  130. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  131. package/dist/esm/fetchers/rest-api.js +3 -1
  132. package/dist/esm/fetchers/rest-api.js.map +1 -1
  133. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  134. package/dist/esm/fetchers/teleport.js +6 -3
  135. package/dist/esm/fetchers/teleport.js.map +1 -1
  136. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
  137. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  138. package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
  139. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  140. package/dist/esm/filter-utils.d.ts +19 -7
  141. package/dist/esm/filter-utils.d.ts.map +1 -1
  142. package/dist/esm/filter-utils.js +46 -12
  143. package/dist/esm/filter-utils.js.map +1 -1
  144. package/dist/esm/infinite-scroll.d.ts +25 -2
  145. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  146. package/dist/esm/infinite-scroll.js +142 -32
  147. package/dist/esm/infinite-scroll.js.map +1 -1
  148. package/dist/esm/list-sort-order.d.ts +14 -0
  149. package/dist/esm/list-sort-order.d.ts.map +1 -0
  150. package/dist/esm/list-sort-order.js +32 -0
  151. package/dist/esm/list-sort-order.js.map +1 -0
  152. package/dist/esm/pagination-controls.d.ts +22 -3
  153. package/dist/esm/pagination-controls.d.ts.map +1 -1
  154. package/dist/esm/pagination-controls.js +28 -2
  155. package/dist/esm/pagination-controls.js.map +1 -1
  156. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  157. package/dist/esm/pagination-plugin.js +271 -216
  158. package/dist/esm/pagination-plugin.js.map +1 -1
  159. package/dist/esm/product-price-sort.d.ts +0 -9
  160. package/dist/esm/product-price-sort.d.ts.map +1 -1
  161. package/dist/esm/product-price-sort.js +0 -11
  162. package/dist/esm/product-price-sort.js.map +1 -1
  163. package/dist/esm/range-control.d.ts +45 -0
  164. package/dist/esm/range-control.d.ts.map +1 -0
  165. package/dist/esm/range-control.js +137 -0
  166. package/dist/esm/range-control.js.map +1 -0
  167. package/dist/esm/row-order.d.ts +20 -0
  168. package/dist/esm/row-order.d.ts.map +1 -0
  169. package/dist/esm/row-order.js +41 -0
  170. package/dist/esm/row-order.js.map +1 -0
  171. package/dist/esm/search-input.d.ts +42 -0
  172. package/dist/esm/search-input.d.ts.map +1 -0
  173. package/dist/esm/search-input.js +129 -0
  174. package/dist/esm/search-input.js.map +1 -0
  175. package/dist/esm/sort-utils.d.ts +10 -0
  176. package/dist/esm/sort-utils.d.ts.map +1 -1
  177. package/dist/esm/sort-utils.js +20 -0
  178. package/dist/esm/sort-utils.js.map +1 -1
  179. package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
  180. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
  181. package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
  182. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
  183. package/dist/esm/transformations/blog-comments.d.ts +1 -1
  184. package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
  185. package/dist/esm/transformations/blog-comments.js +1 -1
  186. package/dist/esm/transformations/blog-comments.js.map +1 -1
  187. package/dist/esm/transformations/blog-post.d.ts +14 -0
  188. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  189. package/dist/esm/transformations/blog-post.js +4 -1
  190. package/dist/esm/transformations/blog-post.js.map +1 -1
  191. package/dist/esm/transformations/booking-item.d.ts +24 -0
  192. package/dist/esm/transformations/booking-item.d.ts.map +1 -0
  193. package/dist/esm/transformations/booking-item.js +23 -0
  194. package/dist/esm/transformations/booking-item.js.map +1 -0
  195. package/dist/esm/transformations/heading-anchors.d.ts +32 -8
  196. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
  197. package/dist/esm/transformations/heading-anchors.js +33 -9
  198. package/dist/esm/transformations/heading-anchors.js.map +1 -1
  199. package/dist/esm/transformations/index.d.ts +27 -13
  200. package/dist/esm/transformations/index.d.ts.map +1 -1
  201. package/dist/esm/transformations/index.js +72 -16
  202. package/dist/esm/transformations/index.js.map +1 -1
  203. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  204. package/dist/esm/utils.d.ts.map +1 -1
  205. package/dist/esm/utils.js +52 -3
  206. package/dist/esm/utils.js.map +1 -1
  207. package/package.json +5 -5
  208. package/src/count-effect.ts +31 -8
  209. package/src/fetchers/pg-client-import.ts +16 -0
  210. package/src/fetchers/postgresql.ts +25 -23
  211. package/src/fetchers/raw-query.ts +2 -1
  212. package/src/fetchers/rest-api.ts +3 -1
  213. package/src/fetchers/teleport.ts +22 -23
  214. package/src/fetchers/utils/browser-row-policy.ts +10 -20
  215. package/src/filter-utils.ts +102 -28
  216. package/src/infinite-scroll.ts +240 -79
  217. package/src/list-sort-order.ts +34 -0
  218. package/src/pagination-controls.ts +26 -3
  219. package/src/pagination-plugin.ts +370 -306
  220. package/src/product-price-sort.ts +0 -11
  221. package/src/range-control.ts +207 -0
  222. package/src/row-order.ts +55 -0
  223. package/src/search-input.ts +190 -0
  224. package/src/sort-utils.ts +25 -0
  225. package/src/transformations/blog-adjacent-posts.ts +89 -3
  226. package/src/transformations/blog-comments.ts +5 -5
  227. package/src/transformations/blog-post.ts +64 -13
  228. package/src/transformations/booking-item.ts +71 -0
  229. package/src/transformations/heading-anchors.ts +79 -16
  230. package/src/transformations/index.ts +104 -14
  231. package/src/utils.ts +74 -3
@@ -8,11 +8,16 @@ import * as types from '@babel/types'
8
8
  import { parseExpression } from '@babel/parser'
9
9
  import { ASTStatementOrder, GenericUtils, StringUtils } from '@teleporthq/teleport-shared'
10
10
  import { ASTUtils, URLQueryWriter, URLSearchParamSync } from '@teleporthq/teleport-plugin-common'
11
- import { generateSafeFileName } from './utils'
11
+ import { collectFilterLeafConditions, generateSafeFileName, pruneFilterTree } from './utils'
12
12
  import { generateDataSourceFetcherWithCore } from './data-source-fetchers'
13
13
  import { buildProductTransformOptions, type EntityTransformOptions } from './transformations'
14
- import { appendSortsParam, DynamicSortAST, extractDynamicSort } from './sort-utils'
15
- import { appendFiltersParam, pushStateIdsAsDeps, pushPropIdsAsDeps } from './filter-utils'
14
+ import { appendSortsParam, DynamicSortAST, extractDynamicSort, readFixedSort } from './sort-utils'
15
+ import {
16
+ appendFiltersParam,
17
+ buildStaticFiltersStringifyCall,
18
+ pushStateIdsAsDeps,
19
+ pushPropIdsAsDeps,
20
+ } from './filter-utils'
16
21
  import { appendItemsPathParam, extractItemsPath } from './items-path'
17
22
  import { resolveRepeaterPerPage } from './repeater-page-size'
18
23
  import {
@@ -32,10 +37,15 @@ import {
32
37
  import {
33
38
  convertControlToButton,
34
39
  dropInactivePaginationControls,
40
+ findAllPaginationControls,
35
41
  findPaginationControl,
42
+ PAGINATION_FOR_ATTR,
43
+ readJSXAttributeString,
36
44
  setJSXExpressionAttribute,
37
45
  type PaginationControlKind,
38
46
  } from './pagination-controls'
47
+ import { findRangeControls, wireRangeControl } from './range-control'
48
+ import { findAllSearchInputsInJSX, pairSearchInputs, wireSearchInput } from './search-input'
39
49
  import { buildCountFetchEffect, buildCountSeqDeclaration } from './count-effect'
40
50
  import {
41
51
  findAllSearchClearButtonsInJSX,
@@ -48,6 +58,7 @@ import {
48
58
  type NumberedPaginationVars,
49
59
  } from './numbered-pagination'
50
60
  import {
61
+ buildAccumulatingRequestMark,
51
62
  buildAccumulatingResponseHandler,
52
63
  buildInfiniteScrollDeclarations,
53
64
  buildSentinelElement,
@@ -121,51 +132,20 @@ function searchDefaultValueInitAST(value: SearchDefaultValue | undefined): types
121
132
  return types.cloneNode(value.ast, /* deep */ true) as types.Expression
122
133
  }
123
134
 
124
- // Builds the `useState(...)` initializer for the search query. When the
125
- // cms-list-repeater is bound to a URL param (`searchUrlParamKey`), the query is
126
- // seeded from `window.location.search` on the client so a deep link such as
127
- // `/products-list?searchKeyword=yoga` arrives pre-filtered on the very first
128
- // paint; the server render (where `window` is undefined) and any missing param
129
- // fall back to the `searchDefaultValue` seed (empty string by default). This
130
- // mirrors the `selectedCategory` / `sortBy` initializer emitted by
131
- // `createStateHookAST` for state-level `urlSearchParamBinding`, keeping the
132
- // three products-list controls consistent.
135
+ // Builds the `useState(...)` initializer for the search query: the
136
+ // `searchDefaultValue` seed (empty string by default), never the address. A
137
+ // statically generated page is rendered on the server without the visitor's
138
+ // `?searchKeyword=`, and the browser's first render must match it — a query
139
+ // seeded from `window.location.search` rendered a filled input and skipped the
140
+ // prefetched rows there, and React warned of a hydration mismatch. A URL-bound
141
+ // search adopts the address after mount instead (`pushSearchUrlSyncEffects`),
142
+ // as the page number does (`page-url-sync.ts`).
133
143
  //
134
144
  // Returns a fresh AST on every call (no shared node references) so the same
135
145
  // initializer can seed both the immediate `ds_N_searchQuery` state and the
136
146
  // debounced `ds_N_state.debouncedQuery` value.
137
147
  function buildSearchQueryInitAST(usage: DataSourceUsage): types.Expression {
138
- const fallback = searchDefaultValueInitAST(usage.searchDefaultValue)
139
- if (!usage.searchUrlParamKey) {
140
- return fallback
141
- }
142
- // (typeof window !== 'undefined'
143
- // ? new URLSearchParams(window.location.search).get('<key>')
144
- // : null) ?? <fallback>
145
- return types.logicalExpression(
146
- '??',
147
- types.conditionalExpression(
148
- types.binaryExpression(
149
- '!==',
150
- types.unaryExpression('typeof', types.identifier('window')),
151
- types.stringLiteral('undefined')
152
- ),
153
- types.callExpression(
154
- types.memberExpression(
155
- types.newExpression(types.identifier('URLSearchParams'), [
156
- types.memberExpression(
157
- types.memberExpression(types.identifier('window'), types.identifier('location')),
158
- types.identifier('search')
159
- ),
160
- ]),
161
- types.identifier('get')
162
- ),
163
- [types.stringLiteral(usage.searchUrlParamKey)]
164
- ),
165
- types.nullLiteral()
166
- ),
167
- fallback
168
- )
148
+ return searchDefaultValueInitAST(usage.searchDefaultValue)
169
149
  }
170
150
 
171
151
  // ==================== UIDL-FIRST STATE MANAGEMENT ====================
@@ -180,6 +160,10 @@ interface DataSourceUsage {
180
160
  dataSourceIdentifier: string
181
161
  // The cms-list-repeater renderPropIdentifier (e.g., "context_1i871")
182
162
  arrayMapperRenderProp: string
163
+ // The array mapper's editor node id (`nodeId` on the cms-list-repeater), which
164
+ // a `range` control placed outside the pagination container names in its
165
+ // `data-tq-pagination-for` attribute. Absent from UIDL exported before it.
166
+ nodeId?: string
183
167
  // Resource definition from UIDL
184
168
  resourceDefinition: {
185
169
  dataSourceId: string
@@ -213,10 +197,11 @@ interface DataSourceUsage {
213
197
  // Dynamic single-column sort bound to component state (set when legacy sorts empty
214
198
  // and cms-list-repeater declares sort/sortDirection fields)
215
199
  dynamicSort?: DynamicSortAST
216
- // Filters from resource params (FLAT condition array — any `{ type: 'group',
217
- // children: [...] }` wrappers from the UIDL inspector have already been
218
- // unwrapped via `flattenFilterGroups`). Downstream emit sites rely on
219
- // entries having `.source` / `.destination` / `.operand` directly.
200
+ // Filters from resource params, as the TREE the inspector authored: condition
201
+ // leaves and `{ type: 'group', operator, children }` entries (see
202
+ // `readFilterTree`). Anything that needs the leaves walks the tree with
203
+ // `collectFilterLeafConditions`; the emit sites write the tree out whole, so
204
+ // an `or` group reaches the data source as an `or`.
220
205
  // tslint:disable-next-line:no-any
221
206
  filters: any[]
222
207
  // State IDs from dynamic filter destinations (for useMemo dependencies).
@@ -272,53 +257,26 @@ interface StateRegistry {
272
257
  byArrayMapperRenderProp: Map<string, DataSourceUsage>
273
258
  }
274
259
 
275
- // The UIDL's `filters.content` array can wrap one or more conditions inside
276
- // a `{ type: 'group', operator: 'and' | 'or', children: [...] }` entry — the
277
- // GUI builds this shape when the inspector adds a logical group. The data
278
- // source API endpoint (`processFilters` in the generated data-source module)
279
- // only knows how to consume a FLAT array of `{ source, destination, operand }`
280
- // conditions, so we walk groups recursively here and collect their leaf
281
- // conditions in order. Anything that isn't a group AND isn't a condition is
282
- // dropped — defensive against partially-built filter entries that would
283
- // otherwise emit `{ source: '', destination: '', operand: '' }` rows the
284
- // API ignores anyway.
285
- //
286
- // `or`-grouped conditions are flattened just like `and`-grouped ones — the
287
- // downstream SQL builder always joins flat filters with `AND`, so emitting
288
- // the conditions side-by-side approximates AND semantics. A future API
289
- // upgrade that respects group operators would key off the original tree
290
- // directly; until then, AND-flattening is the closest correct behaviour and
291
- // matches what the inspector preview shows for the common single-group case
292
- // the GUI emits today.
293
- function flattenFilterGroups(filters: any[]): any[] {
260
+ // The UIDL's `filters.content` array is the tree the inspector authored:
261
+ // `{ type: 'group', operator: 'and' | 'or', children: [...] }` entries around
262
+ // `{ source, destination, operand }` conditions, nested as deep as the user
263
+ // grouped them. The tree is kept as it is — the generated fetchers read it with
264
+ // `normalizeFilterTree`, so an `or` group stays an `or`. Only junk is dropped:
265
+ // an entry that is neither a group nor a condition (a half-built inspector
266
+ // row) would otherwise reach the API as `{ source: '', destination: '' }`.
267
+ // Entries without an explicit `type` are the legacy flat form and are
268
+ // conditions.
269
+ function readFilterTree(filters: any[]): any[] {
294
270
  if (!Array.isArray(filters)) {
295
271
  return []
296
272
  }
297
- const out: any[] = []
298
- const walk = (entry: any): void => {
299
- if (!entry || typeof entry !== 'object') {
300
- return
301
- }
302
- if (entry.type === 'group' && Array.isArray(entry.children)) {
303
- for (const child of entry.children) {
304
- walk(child)
305
- }
306
- return
307
- }
308
- // Backwards-compatible: entries without an explicit `type` (legacy flat
309
- // form) and entries explicitly tagged `condition` are both treated as
310
- // condition leaves.
311
- if (entry.type === undefined || entry.type === 'condition') {
312
- out.push(entry)
313
- }
314
- }
315
- for (const entry of filters) {
316
- walk(entry)
317
- }
318
- return out
273
+ return pruneFilterTree(
274
+ filters,
275
+ (entry: any) => entry.type === undefined || entry.type === 'condition'
276
+ )
319
277
  }
320
278
 
321
- // Walks a flat filter list to extract every `urlSearchParams` reference key
279
+ // Walks the filter tree to extract every `urlSearchParams` reference key
322
280
  // (e.g. `'categoryFilter'`). Used to (a) inject `const router = useRouter()`
323
281
  // once at the top of the component, (b) wire `router.query.<key>` into the
324
282
  // `useMemo` dependency array so the client-side fetch reruns whenever the
@@ -327,7 +285,7 @@ function flattenFilterGroups(filters: any[]): any[] {
327
285
  function collectFilterUrlSearchParamKeys(filters: any[]): string[] {
328
286
  const seen = new Set<string>()
329
287
  const out: string[] = []
330
- for (const f of filters) {
288
+ for (const f of collectFilterLeafConditions(filters) as any[]) {
331
289
  const dest = f?.destination
332
290
  if (!ASTUtils.isUIDLDynamicReference(dest)) {
333
291
  continue
@@ -345,6 +303,16 @@ function collectFilterUrlSearchParamKeys(filters: any[]): string[] {
345
303
  return out
346
304
  }
347
305
 
306
+ // The filter tree with every dynamic destination pruned — what `getStaticProps`
307
+ // can resolve at build time. Groups keep their operator; a group left empty by
308
+ // the pruning disappears with it (`pruneFilterTree`).
309
+ function staticFiltersOf(usage: DataSourceUsage): any[] {
310
+ return pruneFilterTree(
311
+ usage.filters || [],
312
+ (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
313
+ )
314
+ }
315
+
348
316
  // Returns true when any filter destination is a `urlSearchParams` dynamic
349
317
  // reference — used by the plugin to know it needs to import `useRouter` and
350
318
  // emit `const router = useRouter()` at the top of the component. Cheaper to
@@ -550,23 +518,28 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
550
518
  sorts = parentDataSource.resourceParams.sorts.content
551
519
  }
552
520
 
553
- // If legacy sorts aren't set, fall back to the new dynamic single-column
554
- // sort fields on the cms-list-repeater (used by admin-panel listing pages).
521
+ // If legacy sorts aren't set, fall back to the single-column sort fields
522
+ // on the cms-list-repeater. A FIXED one (a column chosen in the editor,
523
+ // e.g. a blog listing's newest first) is a static sort: read as dynamic,
524
+ // it made the list drop the server-side first page (`initialData` below
525
+ // is skipped for a dynamic sort) — the pre-rendered HTML carried no
526
+ // posts, and the browser fetched page 1 a second time. Only a sort
527
+ // bound to state (the admin panel's sortable columns) is dynamic.
555
528
  let dynamicSort: DynamicSortAST | undefined
556
529
  if ((!sorts || sorts.length === 0) && content.sort) {
557
- dynamicSort = extractDynamicSort(content.sort, content.sortDirection)
530
+ const fixedSort = readFixedSort(content.sort, content.sortDirection)
531
+ if (fixedSort) {
532
+ sorts = [fixedSort]
533
+ } else {
534
+ dynamicSort = extractDynamicSort(content.sort, content.sortDirection)
535
+ }
558
536
  }
559
537
 
560
- // Extract filters from parent's resource params. The inspector wraps
561
- // every condition in a `{ type: 'group' }` envelope (single-group or
562
- // nested), so flatten to leaf conditions before the downstream emit
563
- // sites consume `.source` / `.destination` / `.operand` directly —
564
- // they expect a flat condition array. See `flattenFilterGroups`'s
565
- // header comment for why AND-flattening is the correct fallback for
566
- // the API endpoint's flat-condition contract.
538
+ // Extract filters from parent's resource params, keeping the group tree
539
+ // the inspector authored (see `readFilterTree`).
567
540
  let filters: any[] = []
568
541
  if (parentDataSource.resourceParams?.filters?.content) {
569
- filters = flattenFilterGroups(parentDataSource.resourceParams.filters.content)
542
+ filters = readFilterTree(parentDataSource.resourceParams.filters.content)
570
543
  }
571
544
 
572
545
  // Split dynamic destination keys by reference type. `state`/`prop`
@@ -575,7 +548,7 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
575
548
  // `useRouter()` declaration injected separately.
576
549
  const filterStateIds: string[] = []
577
550
  const filterPropIds: string[] = []
578
- for (const f of filters) {
551
+ for (const f of collectFilterLeafConditions(filters) as any[]) {
579
552
  if (!ASTUtils.isUIDLDynamicReference(f.destination)) {
580
553
  continue
581
554
  }
@@ -640,6 +613,7 @@ function buildStateRegistry(uidlNode: any): StateRegistry {
640
613
  index: index++,
641
614
  dataSourceIdentifier: parentDataSource.identifier,
642
615
  arrayMapperRenderProp,
616
+ nodeId: typeof content.nodeId === 'string' ? content.nodeId : undefined,
643
617
  resourceDefinition: {
644
618
  dataSourceId: parentDataSource.resourceDef.dataSourceId || '',
645
619
  tableName: parentDataSource.resourceDef.tableName || '',
@@ -741,6 +715,9 @@ function getStateVarsForUsage(usage: DataSourceUsage): {
741
715
  setPageStateVar: string
742
716
  maxPagesStateVar: string
743
717
  setMaxPagesStateVar: string
718
+ /** The counted row total behind a `range` control; `null` until counted. */
719
+ totalStateVar: string
720
+ setTotalStateVar: string
744
721
  searchQueryVar: string
745
722
  setSearchQueryVar: string
746
723
  debouncedSearchQueryVar: string
@@ -759,6 +736,8 @@ function getStateVarsForUsage(usage: DataSourceUsage): {
759
736
  setPageStateVar: `setDs_${idx}_page`,
760
737
  maxPagesStateVar: `ds_${idx}_maxPages`,
761
738
  setMaxPagesStateVar: `setDs_${idx}_maxPages`,
739
+ totalStateVar: `ds_${idx}_total`,
740
+ setTotalStateVar: `setDs_${idx}_total`,
762
741
  searchQueryVar: `ds_${idx}_searchQuery`,
763
742
  setSearchQueryVar: `setDs_${idx}_searchQuery`,
764
743
  debouncedSearchQueryVar: `ds_${idx}_debouncedQuery`,
@@ -838,21 +817,127 @@ function pushSearchUrlSyncEffects(
838
817
  )
839
818
  : types.identifier(vars.debouncedSearchQueryVar)
840
819
 
820
+ // The query starts at its default (see `buildSearchQueryInitAST`), so the
821
+ // pair must agree on who goes first: the read-back adopts the address, THEN
822
+ // the write-back may write — before that, its first run would delete the very
823
+ // `?<key>=` the read-back is about to adopt.
824
+ const adoptedRefVar = `ds_${usage.index}_searchUrlAdopted`
825
+ const adopterVar = `ds_${usage.index}_adoptUrlSearch`
826
+ effectStatements.push(
827
+ types.variableDeclaration('const', [
828
+ types.variableDeclarator(
829
+ types.identifier(adoptedRefVar),
830
+ types.callExpression(types.identifier('useRef'), [types.booleanLiteral(false)])
831
+ ),
832
+ ]),
833
+ buildUrlSearchAdopter(adopterVar, usage, vars)
834
+ )
835
+
841
836
  // write-back: debounced query → `?<searchUrlParamKey>=`
842
837
  effectStatements.push(
843
838
  URLSearchParamSync.buildUrlWriteBackEffect(
844
839
  usage.searchUrlParamKey,
845
840
  buildDebouncedValueExpr(),
846
- buildDebouncedValueExpr()
841
+ buildDebouncedValueExpr(),
842
+ undefined,
843
+ adoptedRefVar
847
844
  )
848
845
  )
849
- // read-back: `?<searchUrlParamKey>=` → input (`setDs_N_searchQuery`). The
850
- // debounce effect propagates the change into the debounced value + fetch.
846
+ // read-back: `?<searchUrlParamKey>=` → the input AND the applied query at once
847
+ // (`ds_N_adoptUrlSearch`), so an address is not a typed change: it skips the
848
+ // debounce and keeps a page the address carries — `?searchKeyword=x&page=3`
849
+ // lands on page 3.
851
850
  effectStatements.push(
852
- URLSearchParamSync.buildUrlReadBackEffect(usage.searchUrlParamKey, vars.setSearchQueryVar)
851
+ URLSearchParamSync.buildUrlReadBackEffect(
852
+ usage.searchUrlParamKey,
853
+ adopterVar,
854
+ undefined,
855
+ adoptedRefVar
856
+ )
853
857
  )
854
858
  }
855
859
 
860
+ // `const ds_N_adoptUrlSearch = (update) => { … }`: the setter the search's URL
861
+ // read-back calls with its `(prev) => next` updater. It moves the input and the
862
+ // applied (debounced) query together. A list whose page is in the address
863
+ // keeps its page — the page sync sets it from the same address. Any other
864
+ // list starts the new query on page 1, as a typed change does in the debounce
865
+ // effect: an appending list has usually advanced past page 1 by the time the
866
+ // router is ready, and the query's first answer would otherwise be its page 2
867
+ // — empty for a query only a few rows match.
868
+ function buildUrlSearchAdopter(
869
+ adopterVar: string,
870
+ usage: DataSourceUsage,
871
+ vars: ReturnType<typeof getStateVarsForUsage>
872
+ ): types.VariableDeclaration {
873
+ const update = types.identifier('update')
874
+ const pageReset = usageHasPageUrlSync(usage)
875
+ ? []
876
+ : [types.objectProperty(types.identifier('page'), types.numericLiteral(1))]
877
+ const applyToDebounced =
878
+ usage.category === 'paginated+search'
879
+ ? // setDs_N_state((state) => {
880
+ // const next = update(state.debouncedQuery)
881
+ // return next === state.debouncedQuery ? state : { ...state, page: 1, debouncedQuery: next }
882
+ // })
883
+ types.callExpression(types.identifier(vars.setCombinedStateVar), [
884
+ types.arrowFunctionExpression(
885
+ [types.identifier('state')],
886
+ types.blockStatement([
887
+ types.variableDeclaration('const', [
888
+ types.variableDeclarator(
889
+ types.identifier('next'),
890
+ types.callExpression(types.identifier('update'), [
891
+ types.memberExpression(
892
+ types.identifier('state'),
893
+ types.identifier('debouncedQuery')
894
+ ),
895
+ ])
896
+ ),
897
+ ]),
898
+ types.returnStatement(
899
+ types.conditionalExpression(
900
+ types.binaryExpression(
901
+ '===',
902
+ types.identifier('next'),
903
+ types.memberExpression(
904
+ types.identifier('state'),
905
+ types.identifier('debouncedQuery')
906
+ )
907
+ ),
908
+ types.identifier('state'),
909
+ types.objectExpression([
910
+ types.spreadElement(types.identifier('state')),
911
+ ...pageReset,
912
+ types.objectProperty(
913
+ types.identifier('debouncedQuery'),
914
+ types.identifier('next')
915
+ ),
916
+ ])
917
+ )
918
+ ),
919
+ ])
920
+ ),
921
+ ])
922
+ : types.callExpression(types.identifier(vars.setDebouncedSearchQueryVar), [update])
923
+ return types.variableDeclaration('const', [
924
+ types.variableDeclarator(
925
+ types.identifier(adopterVar),
926
+ types.arrowFunctionExpression(
927
+ [types.identifier('update')],
928
+ types.blockStatement([
929
+ types.expressionStatement(
930
+ types.callExpression(types.identifier(vars.setSearchQueryVar), [
931
+ types.identifier('update'),
932
+ ])
933
+ ),
934
+ types.expressionStatement(applyToDebounced),
935
+ ])
936
+ )
937
+ ),
938
+ ])
939
+ }
940
+
856
941
  // How one usage reads and writes its current page.
857
942
  //
858
943
  // A paginated+search list keeps the page inside a combined
@@ -1126,6 +1211,14 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1126
1211
  }
1127
1212
  const blockStatement = arrowFunction.body as types.BlockStatement
1128
1213
 
1214
+ // The "Showing 1–8 of 12" texts. Known up front because a component that
1215
+ // draws one needs the counted TOTAL in state — `ds_N_maxPages` alone cannot
1216
+ // say how many rows there are — and that state, with the live count that
1217
+ // fills it, is declared per usage below. A component without one emits
1218
+ // exactly what it always did.
1219
+ const rangeControls = findRangeControls(blockStatement)
1220
+ const hasRangeControl = rangeControls.length > 0
1221
+
1129
1222
  // STEP 1: Build state registry from UIDL
1130
1223
  const registry = buildStateRegistry(uidl.node)
1131
1224
 
@@ -1258,13 +1351,12 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1258
1351
  ])
1259
1352
  )
1260
1353
 
1261
- // Immediate search query state — seeded with `searchDefaultValue` (or
1262
- // the `searchUrlParamKey` URL value when bound) so the input is
1263
- // pre-filled on mount. The debounced value above is seeded identically
1264
- // so a deep-linked `?searchKeyword=` fetches filtered results on the
1265
- // first paint: the paginated+search `initialData` is gated on
1266
- // `!ds_N_state.debouncedQuery`, so a non-empty seed forces the
1267
- // DataProvider to fetch instead of reusing the unfiltered SSG prefetch.
1354
+ // Immediate search query state — seeded with `searchDefaultValue`, like
1355
+ // the debounced value above. A deep-linked `?searchKeyword=` is adopted
1356
+ // after mount by the URL read-back, which sets both at once; the
1357
+ // paginated+search `initialData` is gated on `!ds_N_state.debouncedQuery`,
1358
+ // so the adopted query makes the DataProvider fetch filtered rows
1359
+ // instead of reusing the unfiltered SSG prefetch the first render shows.
1268
1360
  stateDeclarations.push(
1269
1361
  types.variableDeclaration('const', [
1270
1362
  types.variableDeclarator(
@@ -1441,12 +1533,16 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1441
1533
  pushPropIdsAsDeps(countEffectDeps, countEffectSeen, usage.filterPropIds)
1442
1534
  // Same goes for URL-driven filters (already documented above).
1443
1535
  pushUrlSearchParamMemoDeps(countEffectDeps, usage)
1536
+ if (hasRangeControl) {
1537
+ stateDeclarations.push(buildTotalDeclaration(vars))
1538
+ }
1444
1539
  stateDeclarations.push(buildCountSeqDeclaration(vars.countSeqRefVar))
1445
1540
  effectStatements.push(
1446
1541
  buildCountFetchEffect({
1447
1542
  fileName,
1448
1543
  perPage: usage.perPage,
1449
1544
  setMaxPagesVar: vars.setMaxPagesStateVar,
1545
+ setTotalVar: hasRangeControl ? vars.setTotalStateVar : undefined,
1450
1546
  urlParams,
1451
1547
  deps: countEffectDeps,
1452
1548
  countSeqRefVar: vars.countSeqRefVar,
@@ -1513,8 +1609,10 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1513
1609
  // "Next" button, but not to label a numbered strip or to know when an
1514
1610
  // infinite list has reached its end, so those two modes fetch a live
1515
1611
  // count as well.
1612
+ // A range text needs the live count too: the build-time count only
1613
+ // ever became a page count, never the total it shows.
1516
1614
  const needsLiveCount =
1517
- !isPage || usage.paginationMode === 'numbered' || usage.infiniteScroll
1615
+ !isPage || usage.paginationMode === 'numbered' || usage.infiniteScroll || hasRangeControl
1518
1616
  if (needsLiveCount) {
1519
1617
  const fileName = generateSafeFileName(
1520
1618
  usage.resourceDefinition.dataSourceType,
@@ -1551,12 +1649,16 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1551
1649
  pushPropIdsAsDeps(countDeps, countDepsSeen, usage.filterPropIds)
1552
1650
  pushUrlSearchParamMemoDeps(countDeps, usage)
1553
1651
 
1652
+ if (hasRangeControl) {
1653
+ stateDeclarations.push(buildTotalDeclaration(vars))
1654
+ }
1554
1655
  stateDeclarations.push(buildCountSeqDeclaration(vars.countSeqRefVar))
1555
1656
  effectStatements.push(
1556
1657
  buildCountFetchEffect({
1557
1658
  fileName,
1558
1659
  perPage: usage.perPage,
1559
1660
  setMaxPagesVar: vars.setMaxPagesStateVar,
1661
+ setTotalVar: hasRangeControl ? vars.setTotalStateVar : undefined,
1560
1662
  urlParams: countUrlParams,
1561
1663
  deps: countDeps,
1562
1664
  countSeqRefVar: vars.countSeqRefVar,
@@ -1809,10 +1911,8 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1809
1911
  // An auto-loading list needs something at the end of the rows for its
1810
1912
  // IntersectionObserver to watch.
1811
1913
  if (usage.infiniteScroll && usage.paginated && !usage.infiniteScrollLoadMore) {
1812
- insertSentinelAfterProvider(
1813
- blockStatement,
1814
- dp,
1815
- buildSentinelElement(getInfiniteScrollVars(usage.index))
1914
+ insertSentinelAfterProvider(blockStatement, dp, (parentTag) =>
1915
+ buildSentinelElement(getInfiniteScrollVars(usage.index), parentTag)
1816
1916
  )
1817
1917
  }
1818
1918
 
@@ -1881,25 +1981,17 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1881
1981
  stabilizeDataProviderWithoutRepeater(dp)
1882
1982
  })
1883
1983
 
1884
- // STEP 4: Wire search inputs
1885
- // Match search inputs to usages by order (within each dataSourceIdentifier that has search enabled)
1984
+ // STEP 4: Wire search inputs — a platform input by its place among the
1985
+ // search-enabled mappers, a page's own marked input by the mapper it names
1986
+ // (`search-input.ts`).
1886
1987
  const searchInputs = findAllSearchInputsInJSX(blockStatement)
1887
-
1888
- // Get all search-enabled usages in order
1889
- const searchEnabledUsages = registry.usages.filter((u) => u.searchEnabled)
1890
-
1891
- // Match by order - search input 0 -> searchEnabledUsages[0], etc.
1892
- const usageBySearchInput = new Map<any, DataSourceUsage>()
1893
- searchInputs.forEach((input, idx) => {
1894
- if (idx >= searchEnabledUsages.length) {
1895
- return
1988
+ const usageBySearchInput = pairSearchInputs(searchInputs, registry.usages)
1989
+ for (const input of searchInputs) {
1990
+ const usage = usageBySearchInput.get(input.node)
1991
+ if (usage) {
1992
+ wireSearchInput(input, getStateVarsForUsage(usage))
1896
1993
  }
1897
-
1898
- const usage = searchEnabledUsages[idx]
1899
- const vars = getStateVarsForUsage(usage)
1900
- wireSearchInput(input.node, vars)
1901
- usageBySearchInput.set(input.node, usage)
1902
- })
1994
+ }
1903
1995
 
1904
1996
  // STEP 4.5: Wire the clear buttons, each to the input it shares a parent with.
1905
1997
  findAllSearchClearButtonsInJSX(blockStatement).forEach((button) => {
@@ -1938,6 +2030,25 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1938
2030
  }
1939
2031
  )
1940
2032
 
2033
+ // STEP 5b: Wire the range texts that name their mapper.
2034
+ //
2035
+ // A `range` control carrying `data-tq-pagination-for` belongs to the mapper
2036
+ // whose editor node id it names, wherever it sits — a template's footer or
2037
+ // toolbar is laid out by the author, not inside a pagination container, so
2038
+ // the container-ownership rule above cannot reach it. One without the
2039
+ // attribute was wired by its container in STEP 5; one naming a mapper this
2040
+ // component does not render stays as drawn.
2041
+ rangeControls.forEach((control) => {
2042
+ const ownerNodeId = readJSXAttributeString(control, PAGINATION_FOR_ATTR)
2043
+ if (!ownerNodeId) {
2044
+ return
2045
+ }
2046
+ const owner = registry.usages.find((usage) => usage.nodeId === ownerNodeId)
2047
+ if (owner) {
2048
+ wireRangeControlFor(control, owner, getStateVarsForUsage(owner))
2049
+ }
2050
+ })
2051
+
1941
2052
  // STEP 6: Update getStaticProps if this is a page
1942
2053
  if (isPage) {
1943
2054
  updateGetStaticProps(
@@ -1964,6 +2075,8 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1964
2075
  * It has to be a sibling: the provider's own child is a render-prop function,
1965
2076
  * and anything placed inside it would be re-created on every fetch — the
1966
2077
  * observer would then be watching an element that is no longer in the document.
2078
+ * The sentinel is built for the element it lands in (`buildSentinelElement`):
2079
+ * a fragment is transparent, a component's children may go anywhere.
1967
2080
  *
1968
2081
  * Returns false when the provider has no element parent to splice into (a
1969
2082
  * provider returned directly from the component), leaving the list on its
@@ -1972,35 +2085,53 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1972
2085
  function insertSentinelAfterProvider(
1973
2086
  blockStatement: types.BlockStatement,
1974
2087
  provider: any,
1975
- sentinel: types.JSXElement
2088
+ buildSentinel: (parentTag: string | null) => types.JSXElement
1976
2089
  ): boolean {
1977
2090
  let inserted = false
1978
2091
 
1979
- const traverse = (node: any): void => {
2092
+ const elementTagOf = (node: any, enclosing: string | null): string | null => {
2093
+ if (node?.type === 'JSXFragment') {
2094
+ return enclosing
2095
+ }
2096
+ if (node?.type !== 'JSXElement') {
2097
+ return enclosing
2098
+ }
2099
+ const name = node.openingElement?.name
2100
+ if (name?.type !== 'JSXIdentifier') {
2101
+ return null
2102
+ }
2103
+ if (name.name === 'Fragment') {
2104
+ return enclosing
2105
+ }
2106
+ return /^[a-z]/.test(name.name) ? name.name : null
2107
+ }
2108
+
2109
+ const traverse = (node: any, enclosing: string | null): void => {
1980
2110
  if (!node || inserted) {
1981
2111
  return
1982
2112
  }
2113
+ const tag = elementTagOf(node, enclosing)
1983
2114
  if (Array.isArray(node.children)) {
1984
2115
  const at = node.children.indexOf(provider)
1985
2116
  if (at !== -1) {
1986
- node.children.splice(at + 1, 0, sentinel)
2117
+ node.children.splice(at + 1, 0, buildSentinel(tag))
1987
2118
  inserted = true
1988
2119
  return
1989
2120
  }
1990
- node.children.forEach(traverse)
2121
+ node.children.forEach((child: any) => traverse(child, tag))
1991
2122
  }
1992
2123
  if (node.body) {
1993
2124
  if (Array.isArray(node.body)) {
1994
- node.body.forEach(traverse)
2125
+ node.body.forEach((child: any) => traverse(child, tag))
1995
2126
  } else {
1996
- traverse(node.body)
2127
+ traverse(node.body, tag)
1997
2128
  }
1998
2129
  }
1999
2130
  if (node.consequent) {
2000
- traverse(node.consequent)
2131
+ traverse(node.consequent, tag)
2001
2132
  }
2002
2133
  if (node.alternate) {
2003
- traverse(node.alternate)
2134
+ traverse(node.alternate, tag)
2004
2135
  }
2005
2136
  // `{activeTabIndex === 0 && (<div>…<DataProvider/>…</div>)}` — a tab
2006
2137
  // panel, a gated section: the provider sits on the RIGHT of a logical
@@ -2008,23 +2139,23 @@ function insertSentinelAfterProvider(
2008
2139
  // an argument. Left unvisited, the products list under the first tab of
2009
2140
  // a shop was never paginated or seeded, and rendered nothing.
2010
2141
  if (node.left) {
2011
- traverse(node.left)
2142
+ traverse(node.left, tag)
2012
2143
  }
2013
2144
  if (node.right) {
2014
- traverse(node.right)
2145
+ traverse(node.right, tag)
2015
2146
  }
2016
2147
  if (node.test) {
2017
- traverse(node.test)
2148
+ traverse(node.test, tag)
2018
2149
  }
2019
2150
  if (node.expression) {
2020
- traverse(node.expression)
2151
+ traverse(node.expression, tag)
2021
2152
  }
2022
2153
  if (node.argument) {
2023
- traverse(node.argument)
2154
+ traverse(node.argument, tag)
2024
2155
  }
2025
2156
  }
2026
2157
 
2027
- traverse(blockStatement)
2158
+ traverse(blockStatement, null)
2028
2159
  return inserted
2029
2160
  }
2030
2161
 
@@ -2140,68 +2271,6 @@ function findArrayMapperRenderPropInDataProvider(dataProvider: any): string | un
2140
2271
  return renderItemAttr.value.expression.params[0].name
2141
2272
  }
2142
2273
 
2143
- function findAllSearchInputsInJSX(
2144
- blockStatement: types.BlockStatement
2145
- ): Array<{ node: any; className: string }> {
2146
- const results: Array<{ node: any; className: string }> = []
2147
-
2148
- const traverse = (node: any): void => {
2149
- if (!node) {
2150
- return
2151
- }
2152
-
2153
- if (node.type === 'JSXElement' && node.openingElement?.name?.name === 'input') {
2154
- const classAttr = node.openingElement.attributes?.find(
2155
- (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'className'
2156
- )
2157
- const className = classAttr?.value?.value || classAttr?.value?.expression?.value || ''
2158
- if (className.includes('search-input')) {
2159
- results.push({ node, className })
2160
- }
2161
- }
2162
-
2163
- if (node.children && Array.isArray(node.children)) {
2164
- node.children.forEach((c: any) => traverse(c))
2165
- }
2166
- if (node.body) {
2167
- if (Array.isArray(node.body)) {
2168
- node.body.forEach((s: any) => traverse(s))
2169
- } else {
2170
- traverse(node.body)
2171
- }
2172
- }
2173
- if (node.consequent) {
2174
- traverse(node.consequent)
2175
- }
2176
- if (node.alternate) {
2177
- traverse(node.alternate)
2178
- }
2179
- // `{activeTabIndex === 0 && (<div>…<DataProvider/>…</div>)}` — a tab
2180
- // panel, a gated section: the provider sits on the RIGHT of a logical
2181
- // expression (or in a ternary's `test`), which is not a child, a body or
2182
- // an argument. Left unvisited, the products list under the first tab of
2183
- // a shop was never paginated or seeded, and rendered nothing.
2184
- if (node.left) {
2185
- traverse(node.left)
2186
- }
2187
- if (node.right) {
2188
- traverse(node.right)
2189
- }
2190
- if (node.test) {
2191
- traverse(node.test)
2192
- }
2193
- if (node.expression) {
2194
- traverse(node.expression)
2195
- }
2196
- if (node.argument) {
2197
- traverse(node.argument)
2198
- }
2199
- }
2200
-
2201
- traverse(blockStatement)
2202
- return results
2203
- }
2204
-
2205
2274
  /**
2206
2275
  * Every pagination widget in the JSX, paired with the mapper it belongs to.
2207
2276
  *
@@ -2736,6 +2805,15 @@ function updateDataProviderForPlain(
2736
2805
  usage: DataSourceUsage,
2737
2806
  localized: boolean
2738
2807
  ): void {
2808
+ // A sort bound to state is the visitor's, which the server cannot know: rows
2809
+ // pre-rendered in the table's order would stand in for it, since a seeded
2810
+ // provider skips its first fetch — the paginated lists drop them the same way.
2811
+ if (usage.dynamicSort) {
2812
+ dp.openingElement.attributes = dp.openingElement.attributes.filter(
2813
+ (attr: any) => !(attr.type === 'JSXAttribute' && attr.name?.name === 'initialData')
2814
+ )
2815
+ }
2816
+
2739
2817
  const attrs = dp.openingElement.attributes
2740
2818
 
2741
2819
  const clientCache = usage.cache?.client ? usage.cache : undefined
@@ -2774,13 +2852,15 @@ function updateDataProviderForPlain(
2774
2852
 
2775
2853
  // Find the params attribute. A plain list may have none at all — but the
2776
2854
  // fetch it was just given sends `params` regardless, and in a localized
2777
- // project those have to carry the locale, so the attribute is created.
2855
+ // project those have to carry the locale, and a sorted list its sort, so the
2856
+ // attribute is created.
2778
2857
  let paramsAttrIndex = attrs.findIndex(
2779
2858
  (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'params'
2780
2859
  )
2860
+ const isSorted = usage.sorts.length > 0 || !!usage.dynamicSort
2781
2861
 
2782
2862
  if (paramsAttrIndex === -1) {
2783
- if (!localized) {
2863
+ if (!localized && !isSorted) {
2784
2864
  return
2785
2865
  }
2786
2866
  attrs.push(
@@ -2814,10 +2894,22 @@ function updateDataProviderForPlain(
2814
2894
  return
2815
2895
  }
2816
2896
 
2817
- // Build useMemo dependencies including filter state IDs
2897
+ // The repeater's sort, which the paginated and searchable lists add to the
2898
+ // params they rebuild: kept as they come, a plain list's params never
2899
+ // carried it, and the rows arrived in the table's own order.
2900
+ if (paramsExpression.type === 'ObjectExpression' && !hasObjectKey(paramsExpression, 'sorts')) {
2901
+ appendSortsParam(
2902
+ paramsExpression.properties as types.ObjectProperty[],
2903
+ usage.sorts,
2904
+ usage.dynamicSort
2905
+ )
2906
+ }
2907
+
2908
+ // Build useMemo dependencies including filter state IDs and a dynamic sort's state
2818
2909
  const memoDeps: types.Expression[] = []
2819
2910
  const memoSeen = new Set<string>()
2820
2911
  pushStateIdsAsDeps(memoDeps, memoSeen, usage.filterStateIds)
2912
+ pushStateIdsAsDeps(memoDeps, memoSeen, usage.dynamicSort?.depStateIds ?? [])
2821
2913
  pushPropIdsAsDeps(memoDeps, memoSeen, usage.filterPropIds)
2822
2914
  pushUrlSearchParamMemoDeps(memoDeps, usage)
2823
2915
  if (localized && paramsExpression.type === 'ObjectExpression') {
@@ -2837,6 +2929,14 @@ function updateDataProviderForPlain(
2837
2929
  )
2838
2930
  }
2839
2931
 
2932
+ const hasObjectKey = (object: types.ObjectExpression, key: string): boolean =>
2933
+ object.properties.some(
2934
+ (property) =>
2935
+ property.type === 'ObjectProperty' &&
2936
+ ((property.key.type === 'Identifier' && property.key.name === key) ||
2937
+ (property.key.type === 'StringLiteral' && property.key.value === key))
2938
+ )
2939
+
2840
2940
  function stabilizeDataProviderWithoutRepeater(dp: any): void {
2841
2941
  const attrs = dp.openingElement.attributes
2842
2942
 
@@ -2967,11 +3067,21 @@ function createFetchDataAttribute(
2967
3067
  [responseTail]
2968
3068
  )
2969
3069
 
3070
+ // An appending list records the request it now stands on before making it
3071
+ // (see `buildAccumulatingRequestMark`).
2970
3072
  const arrow = cache
2971
3073
  ? types.arrowFunctionExpression(
2972
3074
  [types.identifier('params')],
2973
3075
  buildCachedFetchBody(networkChain, cache)
2974
3076
  )
3077
+ : accumulate
3078
+ ? types.arrowFunctionExpression(
3079
+ [types.identifier('params')],
3080
+ types.blockStatement([
3081
+ buildAccumulatingRequestMark(getInfiniteScrollVars(accumulate.usage.index)),
3082
+ types.returnStatement(networkChain),
3083
+ ])
3084
+ )
2975
3085
  : types.arrowFunctionExpression([types.identifier('params')], networkChain)
2976
3086
 
2977
3087
  return types.jsxAttribute(
@@ -2982,39 +3092,6 @@ function createFetchDataAttribute(
2982
3092
  )
2983
3093
  }
2984
3094
 
2985
- function wireSearchInput(inputNode: any, vars: ReturnType<typeof getStateVarsForUsage>): void {
2986
- // Remove existing onChange and value
2987
- inputNode.openingElement.attributes = inputNode.openingElement.attributes.filter(
2988
- (attr: any) => !['onChange', 'value'].includes(attr.name?.name)
2989
- )
2990
-
2991
- // Add onChange
2992
- inputNode.openingElement.attributes.push(
2993
- types.jsxAttribute(
2994
- types.jsxIdentifier('onChange'),
2995
- types.jsxExpressionContainer(
2996
- types.arrowFunctionExpression(
2997
- [types.identifier('e')],
2998
- types.callExpression(types.identifier(vars.setSearchQueryVar), [
2999
- types.memberExpression(
3000
- types.memberExpression(types.identifier('e'), types.identifier('target')),
3001
- types.identifier('value')
3002
- ),
3003
- ])
3004
- )
3005
- )
3006
- )
3007
- )
3008
-
3009
- // Add value
3010
- inputNode.openingElement.attributes.push(
3011
- types.jsxAttribute(
3012
- types.jsxIdentifier('value'),
3013
- types.jsxExpressionContainer(types.identifier(vars.searchQueryVar))
3014
- )
3015
- )
3016
- }
3017
-
3018
3095
  /**
3019
3096
  * The `fetchData` attribute for one paginated usage.
3020
3097
  *
@@ -3030,6 +3107,41 @@ function buildFetchDataForUsage(fileName: string, usage: DataSourceUsage): types
3030
3107
  return createFetchDataAttribute(fileName, usage.cache?.client ? usage.cache : undefined)
3031
3108
  }
3032
3109
 
3110
+ /** `const [ds_N_total, setDs_N_total] = useState(null)` — declared only beside a range control. */
3111
+ function buildTotalDeclaration(vars: ReturnType<typeof getStateVarsForUsage>): types.Statement {
3112
+ return types.variableDeclaration('const', [
3113
+ types.variableDeclarator(
3114
+ types.arrayPattern([
3115
+ types.identifier(vars.totalStateVar),
3116
+ types.identifier(vars.setTotalStateVar),
3117
+ ]),
3118
+ types.callExpression(types.identifier('useState'), [types.nullLiteral()])
3119
+ ),
3120
+ ])
3121
+ }
3122
+
3123
+ /**
3124
+ * Fills one `range` text with the live row range of `usage` (see
3125
+ * `range-control.ts`). A mapper that does not page has no count to show, so
3126
+ * its range text is left as drawn.
3127
+ */
3128
+ function wireRangeControlFor(
3129
+ control: any,
3130
+ usage: DataSourceUsage,
3131
+ vars: ReturnType<typeof getStateVarsForUsage>
3132
+ ): void {
3133
+ if (!usage.paginated) {
3134
+ return
3135
+ }
3136
+ const { currentPageExpr } = getPageAccessors(usage, vars)
3137
+ wireRangeControl(control, {
3138
+ totalVar: vars.totalStateVar,
3139
+ currentPageExpr: currentPageExpr(),
3140
+ perPage: usage.perPage,
3141
+ infiniteScroll: usage.infiniteScroll,
3142
+ })
3143
+ }
3144
+
3033
3145
  /**
3034
3146
  * Wires the authored Load More button of an appending list.
3035
3147
  *
@@ -3095,6 +3207,12 @@ function wirePaginationWidget(
3095
3207
  vars: ReturnType<typeof getStateVarsForUsage>,
3096
3208
  hasLoadingState: boolean
3097
3209
  ): void {
3210
+ // A range text inside the container takes the container's owner, like the
3211
+ // buttons beside it; one naming its own mapper is wired in STEP 5b instead.
3212
+ findAllPaginationControls(paginationNode, 'range')
3213
+ .filter((control) => !readJSXAttributeString(control, PAGINATION_FOR_ATTR))
3214
+ .forEach((control) => wireRangeControlFor(control, usage, vars))
3215
+
3098
3216
  if (usage.infiniteScroll) {
3099
3217
  // Only the Load More button survives; page controls make no sense for a
3100
3218
  // list the visitor never leaves.
@@ -3650,38 +3768,12 @@ function updateGetStaticProps(
3650
3768
 
3651
3769
  // Add filters if present
3652
3770
  // Filter out dynamic destinations for getStaticProps (they can't be resolved at build time)
3653
- const staticFilters = (usage.filters || []).filter(
3654
- (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
3655
- )
3771
+ const staticFilters = staticFiltersOf(usage)
3656
3772
  if (staticFilters.length > 0) {
3657
3773
  fetchParams.push(
3658
3774
  types.objectProperty(
3659
3775
  types.identifier('filters'),
3660
- types.callExpression(
3661
- types.memberExpression(types.identifier('JSON'), types.identifier('stringify')),
3662
- [
3663
- types.arrayExpression(
3664
- staticFilters.map((filter: any) =>
3665
- types.objectExpression([
3666
- types.objectProperty(
3667
- types.identifier('source'),
3668
- types.stringLiteral(filter.source || '')
3669
- ),
3670
- types.objectProperty(
3671
- types.identifier('destination'),
3672
- types.stringLiteral(
3673
- typeof filter.destination === 'string' ? filter.destination : ''
3674
- )
3675
- ),
3676
- types.objectProperty(
3677
- types.identifier('operand'),
3678
- types.stringLiteral(filter.operand || '')
3679
- ),
3680
- ])
3681
- )
3682
- ),
3683
- ]
3684
- )
3776
+ buildStaticFiltersStringifyCall(staticFilters)
3685
3777
  )
3686
3778
  )
3687
3779
  }
@@ -3766,9 +3858,7 @@ function updateGetStaticProps(
3766
3858
  const maxPagesPropName = `${vars.propsPrefix}_maxPages`
3767
3859
  // Use filter-specific count variable if usage has static filters
3768
3860
  // (dynamic filters can't be resolved at build time)
3769
- const staticFiltersForCount = (usage.filters || []).filter(
3770
- (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
3771
- )
3861
+ const staticFiltersForCount = staticFiltersOf(usage)
3772
3862
  // A count scoped to an inner array is a different number from the
3773
3863
  // whole-payload one, so it gets its own variable just like a filtered one.
3774
3864
  const hasScopedCount = staticFiltersForCount.length > 0 || !!usage.itemsPath
@@ -3854,9 +3944,7 @@ function updateGetStaticProps(
3854
3944
  usagesByFilters.forEach((usages, filtersKey) => {
3855
3945
  const firstUsage = usages[0]
3856
3946
  // Filter out dynamic destinations for getStaticProps (they can't be resolved at build time)
3857
- const staticFiltersForCount = (firstUsage.filters || []).filter(
3858
- (f: any) => !ASTUtils.isUIDLDynamicReference(f.destination)
3859
- )
3947
+ const staticFiltersForCount = staticFiltersOf(firstUsage)
3860
3948
  const hasStaticFilters = staticFiltersForCount.length > 0
3861
3949
 
3862
3950
  // Create unique count variable name based on filters (and inner-array
@@ -3887,31 +3975,7 @@ function updateGetStaticProps(
3887
3975
  countParams.push(
3888
3976
  types.objectProperty(
3889
3977
  types.identifier('filters'),
3890
- types.callExpression(
3891
- types.memberExpression(types.identifier('JSON'), types.identifier('stringify')),
3892
- [
3893
- types.arrayExpression(
3894
- staticFiltersForCount.map((f: any) =>
3895
- types.objectExpression([
3896
- types.objectProperty(
3897
- types.identifier('source'),
3898
- types.stringLiteral(f.source || '')
3899
- ),
3900
- types.objectProperty(
3901
- types.identifier('destination'),
3902
- types.stringLiteral(
3903
- typeof f.destination === 'string' ? f.destination : ''
3904
- )
3905
- ),
3906
- types.objectProperty(
3907
- types.identifier('operand'),
3908
- types.stringLiteral(f.operand || '')
3909
- ),
3910
- ])
3911
- )
3912
- ),
3913
- ]
3914
- )
3978
+ buildStaticFiltersStringifyCall(staticFiltersForCount)
3915
3979
  )
3916
3980
  )
3917
3981
  }