@teleporthq/teleport-plugin-next-data-source 0.43.57 → 0.43.60

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 (147) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
  2. package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
  3. package/__tests__/ecommerce-product-discounts.test.ts +196 -0
  4. package/__tests__/ecommerce-product-model.test.ts +179 -0
  5. package/__tests__/infinite-scroll.test.ts +143 -0
  6. package/__tests__/numbered-pagination.test.ts +113 -0
  7. package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
  8. package/__tests__/page-url-sync.test.ts +208 -0
  9. package/__tests__/product-price-sort.ts +313 -0
  10. package/__tests__/search-url-sync.test.ts +7 -3
  11. package/dist/cjs/array-mapper-pagination.d.ts +9 -0
  12. package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
  13. package/dist/cjs/array-mapper-pagination.js.map +1 -1
  14. package/dist/cjs/count-effect.d.ts +42 -0
  15. package/dist/cjs/count-effect.d.ts.map +1 -0
  16. package/dist/cjs/count-effect.js +81 -0
  17. package/dist/cjs/count-effect.js.map +1 -0
  18. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/postgresql.js +2 -1
  20. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  21. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/teleport.js +3 -2
  23. package/dist/cjs/fetchers/teleport.js.map +1 -1
  24. package/dist/cjs/infinite-scroll.d.ts +65 -0
  25. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  26. package/dist/cjs/infinite-scroll.js +190 -0
  27. package/dist/cjs/infinite-scroll.js.map +1 -0
  28. package/dist/cjs/numbered-pagination.d.ts +42 -0
  29. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  30. package/dist/cjs/numbered-pagination.js +167 -0
  31. package/dist/cjs/numbered-pagination.js.map +1 -0
  32. package/dist/cjs/page-reset.d.ts +42 -0
  33. package/dist/cjs/page-reset.d.ts.map +1 -0
  34. package/dist/cjs/page-reset.js +116 -0
  35. package/dist/cjs/page-reset.js.map +1 -0
  36. package/dist/cjs/page-url-sync.d.ts +42 -0
  37. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  38. package/dist/cjs/page-url-sync.js +180 -0
  39. package/dist/cjs/page-url-sync.js.map +1 -0
  40. package/dist/cjs/pagination-controls.d.ts +55 -0
  41. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  42. package/dist/cjs/pagination-controls.js +159 -0
  43. package/dist/cjs/pagination-controls.js.map +1 -0
  44. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  45. package/dist/cjs/pagination-plugin.js +506 -160
  46. package/dist/cjs/pagination-plugin.js.map +1 -1
  47. package/dist/cjs/product-price-sort.d.ts +116 -0
  48. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  49. package/dist/cjs/product-price-sort.js +169 -0
  50. package/dist/cjs/product-price-sort.js.map +1 -0
  51. package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
  52. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  53. package/dist/cjs/transformations/ecommerce-product.js +2 -1
  54. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  55. package/dist/cjs/transformations/index.d.ts +2 -1
  56. package/dist/cjs/transformations/index.d.ts.map +1 -1
  57. package/dist/cjs/transformations/index.js +17 -1
  58. package/dist/cjs/transformations/index.js.map +1 -1
  59. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  60. package/dist/cjs/transformations/shared-utils.js +1 -1
  61. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  62. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  63. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  64. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  65. package/dist/esm/array-mapper-pagination.js.map +1 -1
  66. package/dist/esm/count-effect.d.ts +42 -0
  67. package/dist/esm/count-effect.d.ts.map +1 -0
  68. package/dist/esm/count-effect.js +53 -0
  69. package/dist/esm/count-effect.js.map +1 -0
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +2 -1
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  74. package/dist/esm/fetchers/teleport.js +3 -2
  75. package/dist/esm/fetchers/teleport.js.map +1 -1
  76. package/dist/esm/infinite-scroll.d.ts +65 -0
  77. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  78. package/dist/esm/infinite-scroll.js +159 -0
  79. package/dist/esm/infinite-scroll.js.map +1 -0
  80. package/dist/esm/numbered-pagination.d.ts +42 -0
  81. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  82. package/dist/esm/numbered-pagination.js +138 -0
  83. package/dist/esm/numbered-pagination.js.map +1 -0
  84. package/dist/esm/page-reset.d.ts +42 -0
  85. package/dist/esm/page-reset.d.ts.map +1 -0
  86. package/dist/esm/page-reset.js +87 -0
  87. package/dist/esm/page-reset.js.map +1 -0
  88. package/dist/esm/page-url-sync.d.ts +42 -0
  89. package/dist/esm/page-url-sync.d.ts.map +1 -0
  90. package/dist/esm/page-url-sync.js +152 -0
  91. package/dist/esm/page-url-sync.js.map +1 -0
  92. package/dist/esm/pagination-controls.d.ts +55 -0
  93. package/dist/esm/pagination-controls.d.ts.map +1 -0
  94. package/dist/esm/pagination-controls.js +126 -0
  95. package/dist/esm/pagination-controls.js.map +1 -0
  96. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  97. package/dist/esm/pagination-plugin.js +507 -161
  98. package/dist/esm/pagination-plugin.js.map +1 -1
  99. package/dist/esm/product-price-sort.d.ts +116 -0
  100. package/dist/esm/product-price-sort.d.ts.map +1 -0
  101. package/dist/esm/product-price-sort.js +160 -0
  102. package/dist/esm/product-price-sort.js.map +1 -0
  103. package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
  104. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  105. package/dist/esm/transformations/ecommerce-product.js +3 -2
  106. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  107. package/dist/esm/transformations/index.d.ts +2 -1
  108. package/dist/esm/transformations/index.d.ts.map +1 -1
  109. package/dist/esm/transformations/index.js +17 -1
  110. package/dist/esm/transformations/index.js.map +1 -1
  111. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  112. package/dist/esm/transformations/shared-utils.js +1 -1
  113. package/dist/esm/transformations/shared-utils.js.map +1 -1
  114. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +5 -5
  116. package/src/array-mapper-pagination.ts +10 -0
  117. package/src/count-effect.ts +137 -0
  118. package/src/fetchers/postgresql.ts +52 -13
  119. package/src/fetchers/teleport.ts +53 -13
  120. package/src/infinite-scroll.ts +401 -0
  121. package/src/numbered-pagination.ts +292 -0
  122. package/src/page-reset.ts +162 -0
  123. package/src/page-url-sync.ts +241 -0
  124. package/src/pagination-controls.ts +176 -0
  125. package/src/pagination-plugin.ts +755 -346
  126. package/src/product-price-sort.ts +204 -0
  127. package/src/transformations/ecommerce-product.ts +141 -10
  128. package/src/transformations/index.ts +19 -1
  129. package/src/transformations/shared-utils.ts +14 -0
  130. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  131. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  132. package/dist/cjs/array-mapper-registry.js +0 -296
  133. package/dist/cjs/array-mapper-registry.js.map +0 -1
  134. package/dist/cjs/pagination-with-count.d.ts +0 -6
  135. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  136. package/dist/cjs/pagination-with-count.js +0 -63
  137. package/dist/cjs/pagination-with-count.js.map +0 -1
  138. package/dist/esm/array-mapper-registry.d.ts +0 -85
  139. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  140. package/dist/esm/array-mapper-registry.js +0 -292
  141. package/dist/esm/array-mapper-registry.js.map +0 -1
  142. package/dist/esm/pagination-with-count.d.ts +0 -6
  143. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  144. package/dist/esm/pagination-with-count.js +0 -34
  145. package/dist/esm/pagination-with-count.js.map +0 -1
  146. package/src/array-mapper-registry.ts +0 -416
  147. package/src/pagination-with-count.ts +0 -89
@@ -0,0 +1,162 @@
1
+ import * as types from '@babel/types'
2
+
3
+ /**
4
+ * Resetting a paginated list back to page 1 when its FILTER or SORT changes.
5
+ *
6
+ * Without this a visitor on page 5 who picks a category with twelve results
7
+ * fetches page 5 of twelve rows and is shown an empty grid, with only
8
+ * "Previous" to get out of it. The search input never had this bug: its
9
+ * debounce effect REPLACES the whole `{ page, debouncedQuery }` state, and so
10
+ * has always dropped the page back to 1 as a side effect.
11
+ *
12
+ * ⛔ Why the guard is a signature and not the usual `useRef(true)` skip-first:
13
+ * URL-driven dependencies (`router.query.categoryFilter`) are `undefined` on
14
+ * the first render and only take their real value once the router hydrates.
15
+ * A skip-first guard would treat that hydration as a change and reset the page
16
+ * of a deep link like `?page=3&categoryFilter=Rings` back to 1 — the very
17
+ * sharing case the page-in-URL feature exists to support. Comparing a
18
+ * serialised signature instead means the first OBSERVED value is recorded
19
+ * rather than acted on, whatever render it arrives in, and any later genuine
20
+ * change resets exactly once.
21
+ */
22
+
23
+ export interface PageResetDeps {
24
+ /** Bare identifiers: state-bound filter destinations and dynamic-sort deps. */
25
+ stateIds: string[]
26
+ /** Emitted as `props.<id>` — props are never destructured into scope. */
27
+ propIds: string[]
28
+ /** Emitted as `router.query.<key>`. */
29
+ urlSearchParamKeys: string[]
30
+ }
31
+
32
+ /** True when there is nothing that could ever change the filtering of this list. */
33
+ export const hasNoPageResetDeps = (deps: PageResetDeps): boolean =>
34
+ deps.stateIds.length === 0 && deps.propIds.length === 0 && deps.urlSearchParamKeys.length === 0
35
+
36
+ const routerQueryAccess = (key: string): types.MemberExpression =>
37
+ types.memberExpression(
38
+ types.memberExpression(types.identifier('router'), types.identifier('query')),
39
+ types.identifier(key)
40
+ )
41
+
42
+ /**
43
+ * The expressions the effect both depends on and hashes into its signature.
44
+ *
45
+ * Kept in one place so the two can never drift: a value in the signature but
46
+ * not the deps would be read stale, and one in the deps but not the signature
47
+ * would re-run the effect without being able to explain why it changed.
48
+ */
49
+ const buildTrackedExpressions = (deps: PageResetDeps): types.Expression[] => {
50
+ const tracked: types.Expression[] = []
51
+ const seen = new Set<string>()
52
+ for (const id of deps.stateIds) {
53
+ if (seen.has(id)) {
54
+ continue
55
+ }
56
+ seen.add(id)
57
+ tracked.push(types.identifier(id))
58
+ }
59
+ for (const id of deps.propIds) {
60
+ const key = `props.${id}`
61
+ if (seen.has(key)) {
62
+ continue
63
+ }
64
+ seen.add(key)
65
+ tracked.push(types.memberExpression(types.identifier('props'), types.identifier(id)))
66
+ }
67
+ for (const key of deps.urlSearchParamKeys) {
68
+ tracked.push(routerQueryAccess(key))
69
+ }
70
+ return tracked
71
+ }
72
+
73
+ /**
74
+ * Builds the reset effect.
75
+ *
76
+ * @param refVar name of the `useRef` holding the last seen signature
77
+ * @param buildResetStatement how this usage writes page 1 — the combined
78
+ * `{ page, debouncedQuery }` state and the standalone
79
+ * page state need different setters
80
+ */
81
+ export const buildFilterSortPageResetEffect = (
82
+ refVar: string,
83
+ deps: PageResetDeps,
84
+ buildResetStatement: () => types.Statement
85
+ ): types.ExpressionStatement => {
86
+ const body: types.Statement[] = []
87
+ const hasRouterDeps = deps.urlSearchParamKeys.length > 0
88
+
89
+ // Reading `router.query` before the router is ready would hash `undefined`
90
+ // for every URL-driven filter and record a signature that is about to change
91
+ // on its own.
92
+ if (hasRouterDeps) {
93
+ body.push(
94
+ types.ifStatement(
95
+ types.unaryExpression(
96
+ '!',
97
+ types.memberExpression(types.identifier('router'), types.identifier('isReady')),
98
+ true
99
+ ),
100
+ types.returnStatement()
101
+ )
102
+ )
103
+ }
104
+
105
+ const refCurrent = types.memberExpression(types.identifier(refVar), types.identifier('current'))
106
+
107
+ // `?? null` so a missing URL param serialises as `null` rather than dropping
108
+ // out of the array and shifting every later entry.
109
+ const signatureEntries = buildTrackedExpressions(deps).map((expr) =>
110
+ expr.type === 'MemberExpression' && expr.object.type === 'MemberExpression'
111
+ ? types.logicalExpression('??', expr, types.nullLiteral())
112
+ : expr
113
+ )
114
+
115
+ body.push(
116
+ types.variableDeclaration('const', [
117
+ types.variableDeclarator(
118
+ types.identifier('__sig'),
119
+ types.callExpression(
120
+ types.memberExpression(types.identifier('JSON'), types.identifier('stringify')),
121
+ [types.arrayExpression(signatureEntries)]
122
+ )
123
+ ),
124
+ ]),
125
+ types.ifStatement(
126
+ types.binaryExpression('===', types.cloneNode(refCurrent, true), types.identifier('__sig')),
127
+ types.returnStatement()
128
+ ),
129
+ types.variableDeclaration('const', [
130
+ types.variableDeclarator(
131
+ types.identifier('__first'),
132
+ types.binaryExpression('===', types.cloneNode(refCurrent, true), types.nullLiteral())
133
+ ),
134
+ ]),
135
+ types.expressionStatement(
136
+ types.assignmentExpression('=', types.cloneNode(refCurrent, true), types.identifier('__sig'))
137
+ ),
138
+ types.ifStatement(types.identifier('__first'), types.returnStatement()),
139
+ buildResetStatement()
140
+ )
141
+
142
+ const effectDeps = buildTrackedExpressions(deps)
143
+ if (hasRouterDeps) {
144
+ effectDeps.push(types.memberExpression(types.identifier('router'), types.identifier('isReady')))
145
+ }
146
+
147
+ return types.expressionStatement(
148
+ types.callExpression(types.identifier('useEffect'), [
149
+ types.arrowFunctionExpression([], types.blockStatement(body)),
150
+ types.arrayExpression(effectDeps),
151
+ ])
152
+ )
153
+ }
154
+
155
+ /** `const <refVar> = useRef(null)` — the signature store for the effect above. */
156
+ export const buildPageResetRefDeclaration = (refVar: string): types.VariableDeclaration =>
157
+ types.variableDeclaration('const', [
158
+ types.variableDeclarator(
159
+ types.identifier(refVar),
160
+ types.callExpression(types.identifier('useRef'), [types.nullLiteral()])
161
+ ),
162
+ ])
@@ -0,0 +1,241 @@
1
+ import * as types from '@babel/types'
2
+ import { URLQueryWriter } from '@teleporthq/teleport-plugin-common'
3
+
4
+ /**
5
+ * Keeping the current page and the `?page=` query in step.
6
+ *
7
+ * ## Why one effect and not the read-back / write-back PAIR
8
+ *
9
+ * Every other URL-bound control (the category dropdown, the sort select, the
10
+ * search box) uses two independent effects from `URLSearchParamSync`, each
11
+ * bailing when the value it would write is already there. That works because
12
+ * both sides start out agreeing: the state is SEEDED from
13
+ * `window.location.search` in its `useState` initializer, so by the time the
14
+ * effects run there is nothing to reconcile.
15
+ *
16
+ * The page cannot be seeded that way, for a reason that only shows up in a
17
+ * browser: a statically generated page is rendered on the server with page 1,
18
+ * and React does NOT repair mismatched ATTRIBUTES when it hydrates — it keeps
19
+ * the server's DOM and warns. A client that seeded page 3 renders "Previous" as
20
+ * enabled, the server rendered it disabled, and the disabled one is what stays
21
+ * on screen. Worse, it stays for good: React's later re-renders diff against the
22
+ * client's own first render, which already said enabled, so nothing ever
23
+ * repaints it. The rows would be page 3's with no way back to page 2.
24
+ *
25
+ * Adopting the URL in an effect instead makes the page change from 1 to 3 a
26
+ * real state transition, which React renders normally. But a naive PAIR of
27
+ * effects then races: on the render where the router becomes ready, the
28
+ * write-back sees page 1, deletes `?page=3` from the URL, and the read-back it
29
+ * was supposed to cooperate with reads back the page it just erased.
30
+ *
31
+ * So one effect owns both directions and decides which side moved:
32
+ *
33
+ * • the URL moved (a deep link, a `<Link>`, browser back/forward) ⇒ adopt it
34
+ * • the page moved (a click, or the filter reset) ⇒ write it
35
+ *
36
+ * The previously seen URL value is what separates the two, so neither direction
37
+ * has to guess. Page 1 is written as the ABSENCE of the key, so an unpaginated
38
+ * visit keeps a clean URL and a filter reset takes `?page=` back off it.
39
+ *
40
+ * ⚠️ A deep-linked page 2+ therefore costs one extra fetch: the provider mounts
41
+ * on page 1 and refetches when this effect adopts the URL. Correctness over the
42
+ * round trip — the alternative leaves a control the visitor cannot use.
43
+ */
44
+
45
+ // Strict JS identifier check, mirroring `URLSearchParamSync`: a key like
46
+ // `foo-bar` has to go through bracket notation or the emitted code is a
47
+ // subtraction.
48
+ const VALID_JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
49
+
50
+ const accessKeyOf = (object: types.Expression, paramKey: string): types.MemberExpression =>
51
+ VALID_JS_IDENTIFIER.test(paramKey)
52
+ ? types.memberExpression(object, types.identifier(paramKey))
53
+ : types.memberExpression(object, types.stringLiteral(paramKey), true)
54
+
55
+ const routerQueryAccess = (paramKey: string): types.MemberExpression =>
56
+ accessKeyOf(
57
+ types.memberExpression(types.identifier('router'), types.identifier('query')),
58
+ paramKey
59
+ )
60
+
61
+ /** `const <refVar> = useRef(null)` — the last `?page=` value this effect saw. */
62
+ export const buildPageUrlRefDeclaration = (refVar: string): types.VariableDeclaration =>
63
+ types.variableDeclaration('const', [
64
+ types.variableDeclarator(
65
+ types.identifier(refVar),
66
+ types.callExpression(types.identifier('useRef'), [types.nullLiteral()])
67
+ ),
68
+ ])
69
+
70
+ export interface PageUrlSyncOptions {
71
+ paramKey: string
72
+ /** Ref holding the previously seen URL page. */
73
+ refVar: string
74
+ /** How this usage reads its current page. */
75
+ currentPageExpr: () => types.Expression
76
+ /** How this usage writes a page back. */
77
+ buildSetPageStatement: (pageExpr: types.Expression) => types.Expression
78
+ }
79
+
80
+ /**
81
+ * Emitted shape (for `?page=`, a combined `{ page, debouncedQuery }` state):
82
+ *
83
+ * useEffect(() => {
84
+ * if (!router.isReady) return
85
+ * const __urlValue = router.query.page
86
+ * const __raw = typeof __urlValue === 'string' ? __urlValue
87
+ * : Array.isArray(__urlValue) ? __urlValue[0] || '' : ''
88
+ * const __urlPage = Math.max(1, parseInt(__raw, 10) || 1)
89
+ * const __statePage = ds_0_state.page
90
+ * const __seenUrlPage = ds_0_pageUrlRef.current
91
+ * if (__tqQuerySyncRef.current.inFlight === 0 &&
92
+ * __seenUrlPage !== __urlPage && __urlPage !== __statePage) {
93
+ * ds_0_pageUrlRef.current = __urlPage
94
+ * setDs_0_state(state => state.page === __urlPage ? state : { ...state, page: __urlPage })
95
+ * return
96
+ * }
97
+ * ds_0_pageUrlRef.current = __urlPage
98
+ * if (__urlPage === __statePage) return
99
+ * __tqWriteQueryParam('page', __statePage <= 1 ? undefined : __statePage)
100
+ * }, [router.query.page, router.isReady, ds_0_state.page])
101
+ *
102
+ * The write goes through `URLQueryWriter` rather than spreading `router.query`
103
+ * here. This effect and a filter's write-back fire in the SAME flush on every
104
+ * filter change (the reset moves the page), and two effects each merging into
105
+ * their own stale snapshot of `router.query` erase each other's keys — see
106
+ * `url-query-writer.ts` for why that then never settles.
107
+ */
108
+ export const buildPageUrlSyncEffect = (options: PageUrlSyncOptions): types.ExpressionStatement => {
109
+ const refCurrent = (): types.MemberExpression =>
110
+ types.memberExpression(types.identifier(options.refVar), types.identifier('current'))
111
+
112
+ const rememberUrlPage = (): types.Statement =>
113
+ types.expressionStatement(
114
+ types.assignmentExpression('=', refCurrent(), types.identifier('__urlPage'))
115
+ )
116
+
117
+ const effectBody = types.blockStatement([
118
+ types.ifStatement(
119
+ types.unaryExpression(
120
+ '!',
121
+ types.memberExpression(types.identifier('router'), types.identifier('isReady'))
122
+ ),
123
+ types.returnStatement(null)
124
+ ),
125
+ types.variableDeclaration('const', [
126
+ types.variableDeclarator(types.identifier('__urlValue'), routerQueryAccess(options.paramKey)),
127
+ ]),
128
+ // `router.query[key]` is `string | string[] | undefined` in Next's typings.
129
+ types.variableDeclaration('const', [
130
+ types.variableDeclarator(
131
+ types.identifier('__raw'),
132
+ types.conditionalExpression(
133
+ types.binaryExpression(
134
+ '===',
135
+ types.unaryExpression('typeof', types.identifier('__urlValue')),
136
+ types.stringLiteral('string')
137
+ ),
138
+ types.identifier('__urlValue'),
139
+ types.conditionalExpression(
140
+ types.callExpression(
141
+ types.memberExpression(types.identifier('Array'), types.identifier('isArray')),
142
+ [types.identifier('__urlValue')]
143
+ ),
144
+ types.logicalExpression(
145
+ '||',
146
+ types.memberExpression(types.identifier('__urlValue'), types.numericLiteral(0), true),
147
+ types.stringLiteral('')
148
+ ),
149
+ types.stringLiteral('')
150
+ )
151
+ )
152
+ ),
153
+ ]),
154
+ // A missing, unparseable or out-of-range param all mean page 1 — which is
155
+ // also the value written as no key at all, so the two sides agree.
156
+ types.variableDeclaration('const', [
157
+ types.variableDeclarator(
158
+ types.identifier('__urlPage'),
159
+ types.callExpression(
160
+ types.memberExpression(types.identifier('Math'), types.identifier('max')),
161
+ [
162
+ types.numericLiteral(1),
163
+ types.logicalExpression(
164
+ '||',
165
+ types.callExpression(types.identifier('parseInt'), [
166
+ types.identifier('__raw'),
167
+ types.numericLiteral(10),
168
+ ]),
169
+ types.numericLiteral(1)
170
+ ),
171
+ ]
172
+ )
173
+ ),
174
+ ]),
175
+ types.variableDeclaration('const', [
176
+ types.variableDeclarator(types.identifier('__statePage'), options.currentPageExpr()),
177
+ ]),
178
+ types.variableDeclaration('const', [
179
+ types.variableDeclarator(types.identifier('__seenUrlPage'), refCurrent()),
180
+ ]),
181
+ // The URL is the side that moved: a deep link (nothing seen yet), a
182
+ // same-path navigation, or browser back/forward.
183
+ types.ifStatement(
184
+ types.logicalExpression(
185
+ '&&',
186
+ // ⛔ Only when nothing this component asked for is still landing.
187
+ // Mid-flight, `router.query` holds a page the visitor is on their way
188
+ // OUT of (the filter reset writes page 1 while `?page=5` is still in the
189
+ // bar), and adopting it would put them straight back on it.
190
+ URLQueryWriter.buildQuerySettledExpr(),
191
+ types.logicalExpression(
192
+ '&&',
193
+ types.binaryExpression(
194
+ '!==',
195
+ types.identifier('__seenUrlPage'),
196
+ types.identifier('__urlPage')
197
+ ),
198
+ types.binaryExpression(
199
+ '!==',
200
+ types.identifier('__urlPage'),
201
+ types.identifier('__statePage')
202
+ )
203
+ )
204
+ ),
205
+ types.blockStatement([
206
+ rememberUrlPage(),
207
+ types.expressionStatement(options.buildSetPageStatement(types.identifier('__urlPage'))),
208
+ types.returnStatement(null),
209
+ ])
210
+ ),
211
+ rememberUrlPage(),
212
+ types.ifStatement(
213
+ types.binaryExpression('===', types.identifier('__urlPage'), types.identifier('__statePage')),
214
+ types.returnStatement(null)
215
+ ),
216
+ // The page is the side that moved: a control click, or the filter reset.
217
+ // The write goes through the shared query writer so it merges into the last
218
+ // query this component asked for — the filter's own write-back may still be
219
+ // in flight in this very flush. Page 1 is written as the ABSENCE of the key,
220
+ // which the writer expresses as `undefined`.
221
+ URLQueryWriter.buildQueryWriteCall(
222
+ options.paramKey,
223
+ types.conditionalExpression(
224
+ types.binaryExpression('<=', types.identifier('__statePage'), types.numericLiteral(1)),
225
+ types.identifier('undefined'),
226
+ types.identifier('__statePage')
227
+ )
228
+ ),
229
+ ])
230
+
231
+ return types.expressionStatement(
232
+ types.callExpression(types.identifier('useEffect'), [
233
+ types.arrowFunctionExpression([], effectBody),
234
+ types.arrayExpression([
235
+ routerQueryAccess(options.paramKey),
236
+ types.memberExpression(types.identifier('router'), types.identifier('isReady')),
237
+ options.currentPageExpr(),
238
+ ]),
239
+ ])
240
+ )
241
+ }
@@ -0,0 +1,176 @@
1
+ import * as types from '@babel/types'
2
+
3
+ /**
4
+ * How the generator recognises the pagination controls the builder authored.
5
+ *
6
+ * The marker is a STATIC UIDL attribute, which survives the resolver, the JSX
7
+ * printer and the browser verbatim — unlike `elementType`, which the resolver
8
+ * replaces with the node's `semanticType`, and unlike the class name, which is
9
+ * a side effect of the styling pipeline (`ThqPreviousElm` →
10
+ * `products-list-thq-previous-elm`) and so silently renames whenever a node is
11
+ * renamed. The same attribute is readable from the DOM, which is how the
12
+ * pagination-scroll runtime already identifies its own nodes.
13
+ */
14
+ export const PAGINATION_CONTROL_ATTR = 'data-tq-pagination-control'
15
+
16
+ /**
17
+ * `'pages'` is the container the page-number buttons are repeated into and
18
+ * `'page'` the single template button inside it. `'ellipsis'` and `'sentinel'`
19
+ * are reserved for an authored gap marker / scroll sentinel; until a builder
20
+ * emits them both runtimes fall back to generating their own.
21
+ */
22
+ export type PaginationControlKind =
23
+ | 'first'
24
+ | 'previous'
25
+ | 'pages'
26
+ | 'page'
27
+ | 'ellipsis'
28
+ | 'next'
29
+ | 'last'
30
+ | 'load-more'
31
+ | 'sentinel'
32
+
33
+ // tslint:disable:no-any
34
+
35
+ // Reads a JSX attribute's literal string value, covering both the plain
36
+ // `attr="value"` form and the `attr={'value'}` container the UIDL printer emits
37
+ // for some static attributes.
38
+ export function readJSXAttributeString(node: any, attrName: string): string {
39
+ const attr = node?.openingElement?.attributes?.find(
40
+ (candidate: any) => candidate.type === 'JSXAttribute' && candidate.name?.name === attrName
41
+ )
42
+ return attr?.value?.value || attr?.value?.expression?.value || ''
43
+ }
44
+
45
+ /**
46
+ * Depth-first search for one authored pagination control.
47
+ *
48
+ * `legacyClassName` keeps every project generated before the marker existed
49
+ * working: its Previous/Next buttons carry no attribute, so the search falls
50
+ * back to the class-name substring the generator has always matched on. New
51
+ * control kinds have no legacy spelling and are marker-only.
52
+ */
53
+ export function findPaginationControl(
54
+ root: any,
55
+ kind: PaginationControlKind,
56
+ legacyClassName?: string
57
+ ): any {
58
+ const search = (node: any, matches: (candidate: any) => boolean): any => {
59
+ if (!node) {
60
+ return null
61
+ }
62
+ if (node.type === 'JSXElement' && matches(node)) {
63
+ return node
64
+ }
65
+ if (Array.isArray(node.children)) {
66
+ for (const child of node.children) {
67
+ const found = search(child, matches)
68
+ if (found) {
69
+ return found
70
+ }
71
+ }
72
+ }
73
+ return null
74
+ }
75
+
76
+ const byMarker = search(
77
+ root,
78
+ (node) => readJSXAttributeString(node, PAGINATION_CONTROL_ATTR) === kind
79
+ )
80
+ if (byMarker || !legacyClassName) {
81
+ return byMarker
82
+ }
83
+ return search(root, (node) => readJSXAttributeString(node, 'className').includes(legacyClassName))
84
+ }
85
+
86
+ /** Every authored control of one kind, in document order (page-number templates aside, callers want one). */
87
+ export function findAllPaginationControls(root: any, kind: PaginationControlKind): any[] {
88
+ const found: any[] = []
89
+ const traverse = (node: any): void => {
90
+ if (!node) {
91
+ return
92
+ }
93
+ if (
94
+ node.type === 'JSXElement' &&
95
+ readJSXAttributeString(node, PAGINATION_CONTROL_ATTR) === kind
96
+ ) {
97
+ found.push(node)
98
+ }
99
+ if (Array.isArray(node.children)) {
100
+ node.children.forEach(traverse)
101
+ }
102
+ }
103
+ traverse(root)
104
+ return found
105
+ }
106
+
107
+ /**
108
+ * Turns an authored control into a real `<button type="button">`.
109
+ *
110
+ * The builders author these as styled containers (a `div` after the resolver),
111
+ * so without this a keyboard user could never reach them and a click would not
112
+ * register as a form-safe activation. An author-supplied `type` is respected.
113
+ */
114
+ export function convertControlToButton(node: any): void {
115
+ node.openingElement.name.name = 'button'
116
+ if (node.closingElement) {
117
+ node.closingElement.name.name = 'button'
118
+ }
119
+ const hasType = node.openingElement.attributes.some((attr: any) => attr.name?.name === 'type')
120
+ if (!hasType) {
121
+ node.openingElement.attributes.push(
122
+ types.jsxAttribute(types.jsxIdentifier('type'), types.stringLiteral('button'))
123
+ )
124
+ }
125
+ }
126
+
127
+ /** Replaces (never duplicates) one JSX attribute, appending it last. */
128
+ export function setJSXExpressionAttribute(
129
+ node: any,
130
+ attrName: string,
131
+ expression: types.Expression
132
+ ): void {
133
+ node.openingElement.attributes = node.openingElement.attributes.filter(
134
+ (attr: any) => attr.name?.name !== attrName
135
+ )
136
+ node.openingElement.attributes.push(
137
+ types.jsxAttribute(types.jsxIdentifier(attrName), types.jsxExpressionContainer(expression))
138
+ )
139
+ }
140
+
141
+ /** Drops one JSX attribute if present. */
142
+ export function removeJSXAttribute(node: any, attrName: string): void {
143
+ node.openingElement.attributes = node.openingElement.attributes.filter(
144
+ (attr: any) => attr.name?.name !== attrName
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Removes the controls that belong to a pagination mode this mapper is not in —
150
+ * a numbered strip left in a Previous/Next list would render unwired buttons
151
+ * that look clickable and do nothing.
152
+ *
153
+ * Children carrying NO marker are always kept: they are the author's own
154
+ * additions (a page counter, a spacer) and the generator has no business
155
+ * deciding whether they belong.
156
+ */
157
+ export function dropInactivePaginationControls(
158
+ paginationNode: any,
159
+ activeKinds: PaginationControlKind[]
160
+ ): void {
161
+ const active = new Set<string>(activeKinds)
162
+ const prune = (node: any): void => {
163
+ if (!node || !Array.isArray(node.children)) {
164
+ return
165
+ }
166
+ node.children = node.children.filter((child: any) => {
167
+ if (child?.type !== 'JSXElement') {
168
+ return true
169
+ }
170
+ const marker = readJSXAttributeString(child, PAGINATION_CONTROL_ATTR)
171
+ return marker === '' || active.has(marker)
172
+ })
173
+ node.children.forEach(prune)
174
+ }
175
+ prune(paginationNode)
176
+ }