@teleporthq/teleport-plugin-next-data-source 0.43.21 → 0.43.22

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.
@@ -0,0 +1,120 @@
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
+ // A `const TestComponent = (props) => { return <div/> }` shell. The pagination
12
+ // plugin prepends the search/pagination state hooks and splices the URL-sync
13
+ // effects before the return, so a minimal body is enough to observe them — the
14
+ // effects are derived from the UIDL registry, not from the JSX wiring.
15
+ const makeComponentChunk = (): ChunkDefinition => {
16
+ const body = types.blockStatement([
17
+ types.returnStatement(
18
+ types.jsxElement(
19
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
20
+ null,
21
+ [],
22
+ true
23
+ )
24
+ ),
25
+ ])
26
+ const arrow = types.arrowFunctionExpression([types.identifier('props')], body)
27
+ const declaration = types.variableDeclaration('const', [
28
+ types.variableDeclarator(types.identifier('TestComponent'), arrow),
29
+ ])
30
+ return {
31
+ name: 'jsx-component',
32
+ type: ChunkType.AST,
33
+ fileType: FileType.JS,
34
+ linkAfter: [],
35
+ content: declaration,
36
+ meta: {},
37
+ }
38
+ }
39
+
40
+ // A `data-source-list > cms-list-repeater` UIDL: a paginated + search-enabled
41
+ // products list, optionally bound to a URL search-param key.
42
+ // tslint:disable-next-line:no-any
43
+ const makeUidlNode = (searchUrlParamKey?: string): any => ({
44
+ type: 'data-source-list',
45
+ content: {
46
+ renderPropIdentifier: 'items',
47
+ resourceDefinition: {
48
+ dataSourceId: 'ds1',
49
+ tableName: 'products',
50
+ dataSourceType: 'postgresql',
51
+ },
52
+ resource: { params: { queryColumns: { content: ['name'] } } },
53
+ nodes: {
54
+ success: {
55
+ type: 'cms-list-repeater',
56
+ content: {
57
+ renderPropIdentifier: 'product',
58
+ paginated: true,
59
+ perPage: 20,
60
+ searchEnabled: true,
61
+ searchDebounce: 300,
62
+ ...(searchUrlParamKey ? { searchUrlParamKey } : {}),
63
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
64
+ },
65
+ },
66
+ },
67
+ },
68
+ })
69
+
70
+ const runPlugin = async (searchUrlParamKey?: string): Promise<string> => {
71
+ const chunk = makeComponentChunk()
72
+ const structure: ComponentStructure = {
73
+ uidl: { name: 'TestComponent', node: makeUidlNode(searchUrlParamKey) },
74
+ chunks: [chunk],
75
+ dependencies: {},
76
+ options: { dataSources: {}, extractedResources: {} },
77
+ } as never
78
+ const plugin = createNextArrayMapperPaginationPlugin()
79
+ await plugin(structure)
80
+ return generator(chunk.content as types.Node).code
81
+ }
82
+
83
+ describe('pagination plugin — search input URL two-way sync (searchUrlParamKey)', () => {
84
+ it('seeds the query from window.location.search and emits paired read-back/write-back effects', async () => {
85
+ const code = await runPlugin('searchKeyword')
86
+
87
+ // Initial value seeded from the URL on the client (both the immediate input
88
+ // state AND the debounced value, so a deep link fetches filtered on mount).
89
+ const seedMatches = code.match(
90
+ /new URLSearchParams\(window\.location\.search\)\.get\("searchKeyword"\)/g
91
+ )
92
+ expect(seedMatches).not.toBeNull()
93
+ expect((seedMatches || []).length).toBe(2)
94
+ expect(code).toContain('typeof window !== "undefined"')
95
+
96
+ // useRouter is injected for the effects.
97
+ expect(code).toContain('const router = useRouter()')
98
+
99
+ // Write-back (debounced query → URL), keyed on the DEBOUNCED value.
100
+ expect(code).toContain('__nextQuery.searchKeyword = String(ds_0_state.debouncedQuery)')
101
+ expect(code).toContain('if (__nextQuery.searchKeyword === router.query.searchKeyword) return')
102
+ expect(code).toContain('}, [ds_0_state.debouncedQuery, router.isReady])')
103
+
104
+ // Read-back (URL → input), functional setState bail-out (loop-free).
105
+ expect(code).toContain('const __urlValue = router.query.searchKeyword')
106
+ expect(code).toContain('setDs_0_searchQuery(prev => prev === __nextValue ? prev : __nextValue)')
107
+ expect(code).toContain('}, [router.query.searchKeyword, router.isReady])')
108
+ })
109
+
110
+ it('does NOT emit any URL sync when the repeater has no searchUrlParamKey (unchanged behaviour)', async () => {
111
+ const code = await runPlugin(undefined)
112
+
113
+ // Search still works locally (state + debounce), but nothing touches the URL.
114
+ expect(code).toContain('ds_0_searchQuery')
115
+ expect(code).not.toContain('searchKeyword')
116
+ expect(code).not.toContain('window.location.search')
117
+ expect(code).not.toContain('router.replace')
118
+ expect(code).not.toContain('useRouter')
119
+ })
120
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"pagination-plugin.d.ts","sourceRoot":"","sources":["../../src/pagination-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,sBAAsB,EAGvB,MAAM,4BAA4B,CAAA;AAulBnC,eAAO,MAAM,qCAAqC,EAAE,sBAAsB,CAAC,EAAE,CAi3B5E,CAAA"}
1
+ {"version":3,"file":"pagination-plugin.d.ts","sourceRoot":"","sources":["../../src/pagination-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,sBAAsB,EAGvB,MAAM,4BAA4B,CAAA;AA4tBnC,eAAO,MAAM,qCAAqC,EAAE,sBAAsB,CAAC,EAAE,CA63B5E,CAAA"}
@@ -105,6 +105,31 @@ function searchDefaultValueInitAST(value) {
105
105
  // independent AST node — Babel does not support shared references.
106
106
  return types.cloneNode(value.ast, /* deep */ true);
107
107
  }
108
+ // Builds the `useState(...)` initializer for the search query. When the
109
+ // cms-list-repeater is bound to a URL param (`searchUrlParamKey`), the query is
110
+ // seeded from `window.location.search` on the client so a deep link such as
111
+ // `/products-list?searchKeyword=yoga` arrives pre-filtered on the very first
112
+ // paint; the server render (where `window` is undefined) and any missing param
113
+ // fall back to the `searchDefaultValue` seed (empty string by default). This
114
+ // mirrors the `selectedCategory` / `sortBy` initializer emitted by
115
+ // `createStateHookAST` for state-level `urlSearchParamBinding`, keeping the
116
+ // three products-list controls consistent.
117
+ //
118
+ // Returns a fresh AST on every call (no shared node references) so the same
119
+ // initializer can seed both the immediate `ds_N_searchQuery` state and the
120
+ // debounced `ds_N_state.debouncedQuery` value.
121
+ function buildSearchQueryInitAST(usage) {
122
+ var fallback = searchDefaultValueInitAST(usage.searchDefaultValue);
123
+ if (!usage.searchUrlParamKey) {
124
+ return fallback;
125
+ }
126
+ // (typeof window !== 'undefined'
127
+ // ? new URLSearchParams(window.location.search).get('<key>')
128
+ // : null) ?? <fallback>
129
+ return types.logicalExpression('??', types.conditionalExpression(types.binaryExpression('!==', types.unaryExpression('typeof', types.identifier('window')), types.stringLiteral('undefined')), types.callExpression(types.memberExpression(types.newExpression(types.identifier('URLSearchParams'), [
130
+ types.memberExpression(types.memberExpression(types.identifier('window'), types.identifier('location')), types.identifier('search')),
131
+ ]), types.identifier('get')), [types.stringLiteral(usage.searchUrlParamKey)]), types.nullLiteral()), fallback);
132
+ }
108
133
  // The UIDL's `filters.content` array can wrap one or more conditions inside
109
134
  // a `{ type: 'group', operator: 'and' | 'or', children: [...] }` entry — the
110
135
  // GUI builds this shape when the inspector adds a logical group. The data
@@ -390,6 +415,13 @@ function buildStateRegistry(uidlNode) {
390
415
  // For plain mappers, use limit from data-source-list resource params
391
416
  var effectivePerPage = content.paginated ? content.perPage : limit || content.perPage;
392
417
  var searchDefaultValue = parseSearchDefaultValue(content.searchDefaultValue);
418
+ // URL two-way binding key for the search input. Only honoured when it
419
+ // is a non-empty string; blank / non-string values fall back to the
420
+ // plain (non-URL-synced) search behaviour.
421
+ var searchUrlParamKey = typeof content.searchUrlParamKey === 'string' &&
422
+ content.searchUrlParamKey.trim().length > 0
423
+ ? content.searchUrlParamKey.trim()
424
+ : undefined;
393
425
  var usage = {
394
426
  index: index++,
395
427
  dataSourceIdentifier: parentDataSource.identifier,
@@ -404,6 +436,7 @@ function buildStateRegistry(uidlNode) {
404
436
  searchEnabled: !!content.searchEnabled,
405
437
  searchDebounce: content.searchDebounce || 300,
406
438
  searchDefaultValue: searchDefaultValue,
439
+ searchUrlParamKey: searchUrlParamKey,
407
440
  queryColumns: queryColumns,
408
441
  sorts: sorts,
409
442
  dynamicSort: dynamicSort,
@@ -493,6 +526,55 @@ function getStateVarsForUsage(usage) {
493
526
  propsPrefix: "".concat(usage.dataSourceIdentifier, "_ds_").concat(idx),
494
527
  };
495
528
  }
529
+ // True when a usage actually emits the search ⇄ URL sync effects: it must carry
530
+ // a non-empty `searchUrlParamKey` AND own a search query state (only the
531
+ // `paginated+search` and `search-only` categories declare `ds_N_searchQuery`).
532
+ // Single source of truth for both the effect emission and the `useRouter`
533
+ // injection, so a `searchUrlParamKey` mistakenly set on a non-search mapper
534
+ // never injects an unused `const router = useRouter()`.
535
+ function usageHasSearchUrlSync(usage) {
536
+ return (!!usage.searchUrlParamKey &&
537
+ (usage.category === 'paginated+search' || usage.category === 'search-only'));
538
+ }
539
+ // Emits the two URL-sync effects for a search input bound to a URL param
540
+ // (`searchUrlParamKey`), reusing the shared `URLSearchParamSync` builders so the
541
+ // search input behaves exactly like the `selectedCategory` / `sortBy` dropdowns:
542
+ //
543
+ // • read-back (URL → input): browser back/forward, shallow `router.push`,
544
+ // and deep links flow into the immediate `ds_N_searchQuery` input state;
545
+ // the existing debounce effect then carries the change into the debounced
546
+ // value and the data fetch.
547
+ // • write-back (debounced → URL): the DEBOUNCED query is pushed onto the URL
548
+ // so it survives reloads and is shareable. Keying on the debounced value
549
+ // (not the raw input) means the URL updates only after the user stops
550
+ // typing — never on every keystroke — honouring the "search runs after a
551
+ // debounce" contract and avoiding a flood of `router.replace` history
552
+ // churn.
553
+ //
554
+ // Loop-free: the write-back skips when the URL already equals the value, and the
555
+ // read-back uses functional `setState(prev => prev === next ? prev : next)`, so
556
+ // a user edit fires write-back once and the echoed read-back bails without a
557
+ // re-render. No-op unless the usage actually has a search query state
558
+ // (paginated+search or search-only) AND a non-empty `searchUrlParamKey`.
559
+ function pushSearchUrlSyncEffects(effectStatements, usage, vars) {
560
+ if (!usageHasSearchUrlSync(usage)) {
561
+ return;
562
+ }
563
+ // The canonical (debounced) query the URL should reflect. For
564
+ // paginated+search it lives on the combined state object as
565
+ // `ds_N_state.debouncedQuery`; for search-only it is the standalone
566
+ // `ds_N_debouncedQuery` state.
567
+ var buildDebouncedValueExpr = function () {
568
+ return usage.category === 'paginated+search'
569
+ ? types.memberExpression(types.identifier(vars.combinedStateVar), types.identifier('debouncedQuery'))
570
+ : types.identifier(vars.debouncedSearchQueryVar);
571
+ };
572
+ // write-back: debounced query → `?<searchUrlParamKey>=`
573
+ effectStatements.push(teleport_plugin_common_1.URLSearchParamSync.buildUrlWriteBackEffect(usage.searchUrlParamKey, buildDebouncedValueExpr(), buildDebouncedValueExpr()));
574
+ // read-back: `?<searchUrlParamKey>=` → input (`setDs_N_searchQuery`). The
575
+ // debounce effect propagates the change into the debounced value + fetch.
576
+ effectStatements.push(teleport_plugin_common_1.URLSearchParamSync.buildUrlReadBackEffect(usage.searchUrlParamKey, vars.setSearchQueryVar));
577
+ }
496
578
  // ==================== MAIN PLUGIN ====================
497
579
  var createNextArrayMapperPaginationPlugin = function () {
498
580
  var paginationPlugin = function (structure) { return __awaiter(void 0, void 0, void 0, function () {
@@ -608,19 +690,22 @@ var createNextArrayMapperPaginationPlugin = function () {
608
690
  ]), types.callExpression(types.identifier('useState'), [
609
691
  types.objectExpression([
610
692
  types.objectProperty(types.identifier('page'), types.numericLiteral(1)),
611
- types.objectProperty(types.identifier('debouncedQuery'), searchDefaultValueInitAST(usage.searchDefaultValue)),
693
+ types.objectProperty(types.identifier('debouncedQuery'), buildSearchQueryInitAST(usage)),
612
694
  ]),
613
695
  ])),
614
696
  ]));
615
- // Immediate search query state — seeded with `searchDefaultValue`
616
- // when provided so the input is pre-filled on mount.
697
+ // Immediate search query state — seeded with `searchDefaultValue` (or
698
+ // the `searchUrlParamKey` URL value when bound) so the input is
699
+ // pre-filled on mount. The debounced value above is seeded identically
700
+ // so a deep-linked `?searchKeyword=` fetches filtered results on the
701
+ // first paint: the paginated+search `initialData` is gated on
702
+ // `!ds_N_state.debouncedQuery`, so a non-empty seed forces the
703
+ // DataProvider to fetch instead of reusing the unfiltered SSG prefetch.
617
704
  stateDeclarations.push(types.variableDeclaration('const', [
618
705
  types.variableDeclarator(types.arrayPattern([
619
706
  types.identifier(vars.searchQueryVar),
620
707
  types.identifier(vars.setSearchQueryVar),
621
- ]), types.callExpression(types.identifier('useState'), [
622
- searchDefaultValueInitAST(usage.searchDefaultValue),
623
- ])),
708
+ ]), types.callExpression(types.identifier('useState'), [buildSearchQueryInitAST(usage)])),
624
709
  ]));
625
710
  // Debounce effect
626
711
  effectStatements.push(types.expressionStatement(types.callExpression(types.identifier('useEffect'), [
@@ -722,6 +807,8 @@ var createNextArrayMapperPaginationPlugin = function () {
722
807
  types.arrowFunctionExpression([], types.blockStatement(countFetchEffectBody)),
723
808
  types.arrayExpression(countEffectDeps),
724
809
  ])));
810
+ // Two-way URL sync for the search input when bound to a URL param.
811
+ pushSearchUrlSyncEffects(effectStatements, usage, vars);
725
812
  }
726
813
  else if (usage.category === 'paginated-only') {
727
814
  // Simple page state
@@ -786,17 +873,13 @@ var createNextArrayMapperPaginationPlugin = function () {
786
873
  types.variableDeclarator(types.arrayPattern([
787
874
  types.identifier(vars.debouncedSearchQueryVar),
788
875
  types.identifier(vars.setDebouncedSearchQueryVar),
789
- ]), types.callExpression(types.identifier('useState'), [
790
- searchDefaultValueInitAST(usage.searchDefaultValue),
791
- ])),
876
+ ]), types.callExpression(types.identifier('useState'), [buildSearchQueryInitAST(usage)])),
792
877
  ]));
793
878
  stateDeclarations.push(types.variableDeclaration('const', [
794
879
  types.variableDeclarator(types.arrayPattern([
795
880
  types.identifier(vars.searchQueryVar),
796
881
  types.identifier(vars.setSearchQueryVar),
797
- ]), types.callExpression(types.identifier('useState'), [
798
- searchDefaultValueInitAST(usage.searchDefaultValue),
799
- ])),
882
+ ]), types.callExpression(types.identifier('useState'), [buildSearchQueryInitAST(usage)])),
800
883
  ]));
801
884
  // Debounce effect
802
885
  effectStatements.push(types.expressionStatement(types.callExpression(types.identifier('useEffect'), [
@@ -819,11 +902,13 @@ var createNextArrayMapperPaginationPlugin = function () {
819
902
  ])),
820
903
  types.arrayExpression([types.identifier(vars.searchQueryVar)]),
821
904
  ])));
905
+ // Two-way URL sync for the search input when bound to a URL param.
906
+ pushSearchUrlSyncEffects(effectStatements, usage, vars);
822
907
  }
823
908
  });
824
909
  // Insert state declarations at the beginning
825
910
  stateDeclarations.reverse().forEach(function (s) { return blockStatement.body.unshift(s); });
826
- needsUseRouter = registry.usages.some(function (u) { return hasUrlSearchParamFilters(u); });
911
+ needsUseRouter = registry.usages.some(function (u) { return hasUrlSearchParamFilters(u) || usageHasSearchUrlSync(u); });
827
912
  if (needsUseRouter) {
828
913
  if (!dependencies.useRouter) {
829
914
  // Match the shape the sibling Next.js plugins use (i18n locale mapper,