@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.
- package/__tests__/search-url-sync.test.ts +120 -0
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +98 -13
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +99 -14
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/pagination-plugin.ts +159 -14
|
@@ -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;
|
|
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'),
|
|
693
|
+
types.objectProperty(types.identifier('debouncedQuery'), buildSearchQueryInitAST(usage)),
|
|
612
694
|
]),
|
|
613
695
|
])),
|
|
614
696
|
]));
|
|
615
|
-
// Immediate search query state — seeded with `searchDefaultValue`
|
|
616
|
-
// when
|
|
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,
|