@salesforce/ui-bundle-template-feature-react-search 11.10.0 → 11.11.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.
- package/README.md +435 -93
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__popover.tsx +40 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
|
@@ -29,6 +29,8 @@ import type {
|
|
|
29
29
|
SearchConfig,
|
|
30
30
|
SearchHandle,
|
|
31
31
|
SearchScope,
|
|
32
|
+
GlobalPagination,
|
|
33
|
+
MergedResultItem,
|
|
32
34
|
} from "../types";
|
|
33
35
|
import { ALL_SCOPE } from "../types";
|
|
34
36
|
|
|
@@ -52,20 +54,47 @@ interface SourceLocalState {
|
|
|
52
54
|
cursorStack: string[];
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
/** Defaults applied when `config.pagination` is omitted. */
|
|
58
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
59
|
+
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50];
|
|
60
|
+
|
|
61
|
+
interface ResolvedPagination {
|
|
62
|
+
mode: "per-source" | "merged";
|
|
63
|
+
mergeOrder: "sequential" | "interleaved" | "proportional";
|
|
64
|
+
pageSize: number;
|
|
65
|
+
pageSizeOptions: number[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolves the single global pagination config, filling in defaults. */
|
|
69
|
+
function resolvePagination(config: SearchConfig): ResolvedPagination {
|
|
70
|
+
const p = config.pagination;
|
|
71
|
+
const pageSizeOptions =
|
|
72
|
+
p?.pageSizeOptions && p.pageSizeOptions.length > 0
|
|
73
|
+
? p.pageSizeOptions
|
|
74
|
+
: DEFAULT_PAGE_SIZE_OPTIONS;
|
|
75
|
+
return {
|
|
76
|
+
mode: p?.mode ?? "per-source",
|
|
77
|
+
mergeOrder: p?.mergeOrder ?? "sequential",
|
|
78
|
+
pageSize: p?.pageSize ?? pageSizeOptions[0] ?? DEFAULT_PAGE_SIZE,
|
|
79
|
+
pageSizeOptions,
|
|
80
|
+
};
|
|
57
81
|
}
|
|
58
82
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
/** Clamps a requested size to the configured options. */
|
|
84
|
+
function validatePageSize(pagination: ResolvedPagination, size: number): number {
|
|
85
|
+
const valid = pagination.pageSizeOptions;
|
|
86
|
+
if (valid.length === 0) return size;
|
|
87
|
+
return valid.includes(size) ? size : pagination.pageSize;
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
function initSourceState(
|
|
66
|
-
|
|
90
|
+
function initSourceState(
|
|
91
|
+
source: SObjectSourceConfig,
|
|
92
|
+
params: URLSearchParams,
|
|
93
|
+
pagination: ResolvedPagination,
|
|
94
|
+
): SourceLocalState {
|
|
95
|
+
const read = readSourceParams(params, source.key, source.filterBy ?? []);
|
|
67
96
|
const sort = read.sort ?? source.defaultSort ?? null;
|
|
68
|
-
const pageSize = validatePageSize(
|
|
97
|
+
const pageSize = validatePageSize(pagination, read.pageSize ?? pagination.pageSize);
|
|
69
98
|
return {
|
|
70
99
|
filters: read.filters,
|
|
71
100
|
sort,
|
|
@@ -93,11 +122,21 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
93
122
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
94
123
|
const lockedScope = options?.lockedScope;
|
|
95
124
|
|
|
125
|
+
// Single global pagination config — page size, options, mode, merge order.
|
|
126
|
+
// There is no per-source pagination; this applies to every scope, including
|
|
127
|
+
// locked single-object pages and dropdown-narrowed scope.
|
|
128
|
+
const pagination = useMemo(
|
|
129
|
+
() => resolvePagination(config),
|
|
130
|
+
// config is stable for the hook's lifetime.
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
[],
|
|
133
|
+
);
|
|
134
|
+
|
|
96
135
|
// One-time seed from URL.
|
|
97
136
|
const initialSourceStates = useMemo(() => {
|
|
98
137
|
const map: Record<string, SourceLocalState> = {};
|
|
99
138
|
for (const source of config.sources) {
|
|
100
|
-
map[source.key] = initSourceState(source, searchParams);
|
|
139
|
+
map[source.key] = initSourceState(source, searchParams, pagination);
|
|
101
140
|
}
|
|
102
141
|
return map;
|
|
103
142
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -120,6 +159,16 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
120
159
|
const [sourceStates, setSourceStates] =
|
|
121
160
|
useState<Record<string, SourceLocalState>>(initialSourceStates);
|
|
122
161
|
|
|
162
|
+
// Merged pagination treats the cumulative result set as one fixed-size-paged
|
|
163
|
+
// list (see SearchPaginationConfig). It needs a single global page index +
|
|
164
|
+
// size rather than the per-source cursors above; per-source state is still
|
|
165
|
+
// kept (for filters / sort) but its cursors stay at page 0.
|
|
166
|
+
const mergedMode = pagination.mode === "merged";
|
|
167
|
+
const mergeOrder = pagination.mergeOrder;
|
|
168
|
+
|
|
169
|
+
const [globalPageIndex, setGlobalPageIndex] = useState(0);
|
|
170
|
+
const [globalPageSize, setGlobalPageSize] = useState(pagination.pageSize);
|
|
171
|
+
|
|
123
172
|
// Snapshot ref so debounced URL sync sees the latest state without
|
|
124
173
|
// recreating callbacks on every render.
|
|
125
174
|
const stateRef = useRef({ q, scope, sourceStates });
|
|
@@ -148,15 +197,15 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
148
197
|
state.sort,
|
|
149
198
|
state.pageSize,
|
|
150
199
|
state.pageIndex,
|
|
151
|
-
// Omit sort / page size from the URL when they equal
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
{ sort: source.defaultSort ?? null, pageSize:
|
|
200
|
+
// Omit sort / page size from the URL when they equal the
|
|
201
|
+
// defaults, so a reset (or untouched source) leaves a clean
|
|
202
|
+
// query string instead of echoing the defaults.
|
|
203
|
+
{ sort: source.defaultSort ?? null, pageSize: pagination.pageSize },
|
|
155
204
|
);
|
|
156
205
|
}
|
|
157
206
|
setSearchParams(params, { replace: true });
|
|
158
207
|
},
|
|
159
|
-
[config.sources, setSearchParams, lockedScope],
|
|
208
|
+
[config.sources, setSearchParams, lockedScope, pagination.pageSize],
|
|
160
209
|
);
|
|
161
210
|
|
|
162
211
|
const debouncedSyncRef = useRef(debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS));
|
|
@@ -171,6 +220,9 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
171
220
|
updater: (prev: SourceLocalState) => SourceLocalState,
|
|
172
221
|
resetPagination: boolean,
|
|
173
222
|
) => {
|
|
223
|
+
// A filter / sort / page-size change reshapes the result set, so the
|
|
224
|
+
// global merged page returns to the first page too.
|
|
225
|
+
if (resetPagination) setGlobalPageIndex(0);
|
|
174
226
|
setSourceStates((prev) => {
|
|
175
227
|
const prior = prev[sourceKey];
|
|
176
228
|
if (!prior) return prev;
|
|
@@ -190,6 +242,8 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
190
242
|
|
|
191
243
|
const setQ = useCallback((nextQ: string) => {
|
|
192
244
|
setQState(nextQ);
|
|
245
|
+
// A new term changes the whole result set — back to the first page.
|
|
246
|
+
setGlobalPageIndex(0);
|
|
193
247
|
// Changing the global term invalidates every cursor.
|
|
194
248
|
setSourceStates((prev) => {
|
|
195
249
|
const next: Record<string, SourceLocalState> = {};
|
|
@@ -213,6 +267,8 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
213
267
|
? nextScope
|
|
214
268
|
: ALL_SCOPE;
|
|
215
269
|
setScopeState(validated);
|
|
270
|
+
// The visible set changes — reset the global page too.
|
|
271
|
+
setGlobalPageIndex(0);
|
|
216
272
|
// Narrowing or widening invalidates every cursor — the visible result
|
|
217
273
|
// set is changing.
|
|
218
274
|
setSourceStates((prev) => {
|
|
@@ -231,6 +287,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
231
287
|
|
|
232
288
|
const resetAll = useCallback(() => {
|
|
233
289
|
setQState("");
|
|
290
|
+
setGlobalPageIndex(0);
|
|
234
291
|
// Preserve locked scope; otherwise widen back to "all".
|
|
235
292
|
const nextScope: SearchScope = lockedScope ?? ALL_SCOPE;
|
|
236
293
|
setScopeState(nextScope);
|
|
@@ -239,15 +296,16 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
239
296
|
empty[source.key] = {
|
|
240
297
|
filters: [],
|
|
241
298
|
sort: source.defaultSort ?? null,
|
|
242
|
-
pageSize:
|
|
299
|
+
pageSize: pagination.pageSize,
|
|
243
300
|
pageIndex: 0,
|
|
244
301
|
afterCursor: undefined,
|
|
245
302
|
cursorStack: [],
|
|
246
303
|
};
|
|
247
304
|
}
|
|
248
305
|
setSourceStates(empty);
|
|
306
|
+
setGlobalPageSize(pagination.pageSize);
|
|
249
307
|
syncToUrl("", nextScope, empty);
|
|
250
|
-
}, [config.sources, syncToUrl, lockedScope]);
|
|
308
|
+
}, [config.sources, syncToUrl, lockedScope, pagination.pageSize]);
|
|
251
309
|
|
|
252
310
|
// -- Fetch -----------------------------------------------------------------
|
|
253
311
|
|
|
@@ -259,6 +317,10 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
259
317
|
return JSON.stringify({
|
|
260
318
|
q,
|
|
261
319
|
scope,
|
|
320
|
+
// Merged mode paginates globally: every in-scope source fetches the
|
|
321
|
+
// same front-anchored window, so the global page/size drive the fetch
|
|
322
|
+
// rather than per-source cursors.
|
|
323
|
+
m: mergedMode ? { gp: globalPageIndex, gs: globalPageSize } : null,
|
|
262
324
|
s: config.sources.map((source) => {
|
|
263
325
|
if (!isSourceInScope(scope, source.key)) {
|
|
264
326
|
return { k: source.key, skip: true };
|
|
@@ -268,12 +330,13 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
268
330
|
k: source.key,
|
|
269
331
|
f: st?.filters,
|
|
270
332
|
o: st?.sort,
|
|
271
|
-
|
|
272
|
-
|
|
333
|
+
// In merged mode per-source size/cursor are overridden below.
|
|
334
|
+
p: mergedMode ? undefined : st?.pageSize,
|
|
335
|
+
a: mergedMode ? undefined : (st?.afterCursor ?? null),
|
|
273
336
|
};
|
|
274
337
|
}),
|
|
275
338
|
});
|
|
276
|
-
}, [q, scope, sourceStates, config.sources]);
|
|
339
|
+
}, [q, scope, sourceStates, config.sources, mergedMode, globalPageIndex, globalPageSize]);
|
|
277
340
|
|
|
278
341
|
const [results, setResults] = useState<Record<string, SourceResult>>({});
|
|
279
342
|
const [loading, setLoading] = useState(true);
|
|
@@ -294,6 +357,13 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
294
357
|
return;
|
|
295
358
|
}
|
|
296
359
|
|
|
360
|
+
// Merged mode: fetch the whole front-anchored window — (page + 1) * size
|
|
361
|
+
// rows — from every in-scope source, with no cursor. Concatenating these
|
|
362
|
+
// and slicing [page*size, (page+1)*size) yields the exact global page,
|
|
363
|
+
// because the first (page+1)*size items of the concatenation are always
|
|
364
|
+
// the true merged prefix regardless of how rows interleave.
|
|
365
|
+
const mergedFetchSize = (globalPageIndex + 1) * globalPageSize;
|
|
366
|
+
|
|
297
367
|
const requests = activeSources.map((source) => {
|
|
298
368
|
const st = sourceStates[source.key]!;
|
|
299
369
|
return {
|
|
@@ -301,8 +371,8 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
301
371
|
q,
|
|
302
372
|
filters: st.filters,
|
|
303
373
|
sort: st.sort,
|
|
304
|
-
pageSize: st.pageSize,
|
|
305
|
-
afterCursor: st.afterCursor,
|
|
374
|
+
pageSize: mergedMode ? mergedFetchSize : st.pageSize,
|
|
375
|
+
afterCursor: mergedMode ? undefined : st.afterCursor,
|
|
306
376
|
};
|
|
307
377
|
});
|
|
308
378
|
|
|
@@ -360,7 +430,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
360
430
|
const setPageSize = (size: number) => {
|
|
361
431
|
updateSource(
|
|
362
432
|
source.key,
|
|
363
|
-
(prev) => ({ ...prev, pageSize: validatePageSize(
|
|
433
|
+
(prev) => ({ ...prev, pageSize: validatePageSize(pagination, size) }),
|
|
364
434
|
true,
|
|
365
435
|
);
|
|
366
436
|
};
|
|
@@ -410,7 +480,8 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
410
480
|
set: setSort,
|
|
411
481
|
},
|
|
412
482
|
pagination: {
|
|
413
|
-
pageSize: state?.pageSize ??
|
|
483
|
+
pageSize: state?.pageSize ?? pagination.pageSize,
|
|
484
|
+
pageSizeOptions: pagination.pageSizeOptions,
|
|
414
485
|
pageIndex: state?.pageIndex ?? 0,
|
|
415
486
|
hasNextPage: result?.pageInfo?.hasNextPage ?? false,
|
|
416
487
|
hasPreviousPage: (state?.pageIndex ?? 0) > 0,
|
|
@@ -421,10 +492,184 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
421
492
|
};
|
|
422
493
|
}
|
|
423
494
|
return out;
|
|
424
|
-
}, [config.sources, sourceStates, results, loading, error, updateSource]);
|
|
495
|
+
}, [config.sources, sourceStates, results, loading, error, updateSource, pagination]);
|
|
425
496
|
|
|
426
497
|
const scopeLocked = lockedScope !== undefined;
|
|
427
498
|
|
|
499
|
+
// -- Aggregate (cross-source) view -----------------------------------------
|
|
500
|
+
|
|
501
|
+
const inScopeSources = useMemo(
|
|
502
|
+
() =>
|
|
503
|
+
config.sources
|
|
504
|
+
.map((s) => controllers[s.key])
|
|
505
|
+
.filter((c): c is SourceController => !!c && isSourceInScope(scope, c.config.key)),
|
|
506
|
+
[config.sources, controllers, scope],
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Cumulative result count is mode-independent: sum the in-scope totals.
|
|
510
|
+
const totalCount = useMemo(
|
|
511
|
+
() =>
|
|
512
|
+
inScopeSources.reduce(
|
|
513
|
+
(sum, c) => sum + (c.result?.totalCount ?? c.result?.nodes.length ?? 0),
|
|
514
|
+
0,
|
|
515
|
+
),
|
|
516
|
+
[inScopeSources],
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
// Every in-scope source's fetched nodes flattened into one list, ordered per
|
|
520
|
+
// `mergeOrder`:
|
|
521
|
+
// - sequential: all of source 1, then source 2, … (config order).
|
|
522
|
+
// - interleaved: round-robin, one node per source per round — equal priority.
|
|
523
|
+
// - proportional: each item gets a key (i + 0.5) / weight (weight = source's
|
|
524
|
+
// totalCount); sorting by key spreads each source evenly across the list
|
|
525
|
+
// in proportion to its size, so larger sources appear more often.
|
|
526
|
+
// All three stay exact in merged mode. Each source fetched (page + 1) * size
|
|
527
|
+
// rows from its front, so the first (page + 1) * size items of this list are
|
|
528
|
+
// the true cumulative prefix: the round index / sort position of any item in
|
|
529
|
+
// that prefix needs at most that many rows from a single source, and those
|
|
530
|
+
// rows were all fetched, so per-source truncation never disturbs the slice.
|
|
531
|
+
// Proportional uses the true totalCount as the weight (not the fetched
|
|
532
|
+
// length), keeping each item's key — and thus the order — stable across pages.
|
|
533
|
+
const concatenatedNodes = useMemo<MergedResultItem[]>(() => {
|
|
534
|
+
const lists = inScopeSources.map((c) => ({
|
|
535
|
+
key: c.config.key,
|
|
536
|
+
nodes: c.result?.nodes ?? [],
|
|
537
|
+
// Proportional weight: the source's true total, falling back to how many
|
|
538
|
+
// we actually have when totalCount is absent.
|
|
539
|
+
weight: c.result?.totalCount ?? c.result?.nodes.length ?? 0,
|
|
540
|
+
}));
|
|
541
|
+
const out: MergedResultItem[] = [];
|
|
542
|
+
|
|
543
|
+
if (mergeOrder === "interleaved") {
|
|
544
|
+
const maxLen = lists.reduce((m, l) => Math.max(m, l.nodes.length), 0);
|
|
545
|
+
for (let round = 0; round < maxLen; round++) {
|
|
546
|
+
for (const l of lists) {
|
|
547
|
+
if (round < l.nodes.length) out.push({ sourceKey: l.key, node: l.nodes[round] });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} else if (mergeOrder === "proportional") {
|
|
551
|
+
// Tag each node with its spread key + source index, then stable-sort.
|
|
552
|
+
const tagged = lists.flatMap((l, sIdx) =>
|
|
553
|
+
l.nodes.map((node, i) => ({
|
|
554
|
+
sourceKey: l.key,
|
|
555
|
+
node,
|
|
556
|
+
sIdx,
|
|
557
|
+
i,
|
|
558
|
+
sortKey: (i + 0.5) / (l.weight > 0 ? l.weight : 1),
|
|
559
|
+
})),
|
|
560
|
+
);
|
|
561
|
+
tagged.sort((a, b) => a.sortKey - b.sortKey || a.sIdx - b.sIdx || a.i - b.i);
|
|
562
|
+
for (const t of tagged) out.push({ sourceKey: t.sourceKey, node: t.node });
|
|
563
|
+
} else {
|
|
564
|
+
for (const l of lists) {
|
|
565
|
+
for (const node of l.nodes) out.push({ sourceKey: l.key, node });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}, [inScopeSources, mergeOrder]);
|
|
570
|
+
|
|
571
|
+
// -- Merged mode: one fixed-size-paged list over the cumulative results -----
|
|
572
|
+
|
|
573
|
+
const mergedGlobalPagination = useMemo<GlobalPagination>(() => {
|
|
574
|
+
// Options come from the global pagination config, NOT the in-scope source —
|
|
575
|
+
// so narrowing the dropdown to one object keeps the global page sizes.
|
|
576
|
+
const pageSizeOptions = pagination.pageSizeOptions;
|
|
577
|
+
const pageCount = totalCount === 0 ? 0 : Math.ceil(totalCount / globalPageSize);
|
|
578
|
+
// Clamp in case totalCount shrank (e.g. a filter) below the current page.
|
|
579
|
+
const pageIndex = pageCount === 0 ? 0 : Math.min(globalPageIndex, pageCount - 1);
|
|
580
|
+
return {
|
|
581
|
+
pageIndex,
|
|
582
|
+
pageCount,
|
|
583
|
+
pageSize: globalPageSize,
|
|
584
|
+
pageSizeOptions,
|
|
585
|
+
hasNextPage: pageIndex + 1 < pageCount,
|
|
586
|
+
hasPreviousPage: pageIndex > 0,
|
|
587
|
+
totalCount,
|
|
588
|
+
goToNextPage: () => setGlobalPageIndex((p) => p + 1),
|
|
589
|
+
goToPreviousPage: () => setGlobalPageIndex((p) => Math.max(0, p - 1)),
|
|
590
|
+
goToPage: (next: number) => setGlobalPageIndex(Math.max(0, Math.floor(next))),
|
|
591
|
+
setPageSize: (size: number) => {
|
|
592
|
+
setGlobalPageSize(size);
|
|
593
|
+
setGlobalPageIndex(0);
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}, [pagination, totalCount, globalPageSize, globalPageIndex]);
|
|
597
|
+
|
|
598
|
+
const mergedModeResults = useMemo<MergedResultItem[]>(() => {
|
|
599
|
+
const start = mergedGlobalPagination.pageIndex * globalPageSize;
|
|
600
|
+
return concatenatedNodes.slice(start, start + globalPageSize);
|
|
601
|
+
}, [concatenatedNodes, mergedGlobalPagination.pageIndex, globalPageSize]);
|
|
602
|
+
|
|
603
|
+
// -- Per-source mode: independent cursors, "global page" = furthest source --
|
|
604
|
+
//
|
|
605
|
+
// Sources paginate with independent cursors (there is no global offset), so a
|
|
606
|
+
// "global page" is the furthest-advanced in-scope source's page. A source
|
|
607
|
+
// that runs out of pages is left behind at a lower pageIndex and stops
|
|
608
|
+
// contributing nodes — its last page was already shown on an earlier global
|
|
609
|
+
// page, so re-emitting it would duplicate rows in the merged list.
|
|
610
|
+
|
|
611
|
+
const perSourceGlobalPagination = useMemo<GlobalPagination>(() => {
|
|
612
|
+
const lead = inScopeSources[0];
|
|
613
|
+
const pageSize = lead?.pagination.pageSize ?? pagination.pageSize;
|
|
614
|
+
const pageSizeOptions = pagination.pageSizeOptions;
|
|
615
|
+
|
|
616
|
+
const pageIndex = inScopeSources.reduce((max, c) => Math.max(max, c.pagination.pageIndex), 0);
|
|
617
|
+
|
|
618
|
+
// Pages a source spans: prefer totalCount; otherwise a lower bound from its
|
|
619
|
+
// own position (current page, plus one more if it reports a next page).
|
|
620
|
+
const pageCount = inScopeSources.reduce((max, c) => {
|
|
621
|
+
const tc = c.result?.totalCount;
|
|
622
|
+
const span =
|
|
623
|
+
tc != null && pageSize > 0
|
|
624
|
+
? Math.max(1, Math.ceil(tc / pageSize))
|
|
625
|
+
: c.pagination.pageIndex + (c.pagination.hasNextPage ? 2 : 1);
|
|
626
|
+
return Math.max(max, span);
|
|
627
|
+
}, 0);
|
|
628
|
+
|
|
629
|
+
const hasNextPage = inScopeSources.some((c) => c.pagination.hasNextPage);
|
|
630
|
+
const hasPreviousPage = pageIndex > 0;
|
|
631
|
+
|
|
632
|
+
const goToNextPage = () =>
|
|
633
|
+
inScopeSources.forEach((c) => {
|
|
634
|
+
if (c.pagination.hasNextPage) c.pagination.goToNextPage();
|
|
635
|
+
});
|
|
636
|
+
const goToPreviousPage = () =>
|
|
637
|
+
inScopeSources.forEach((c) => {
|
|
638
|
+
if (c.pagination.pageIndex === pageIndex) c.pagination.goToPreviousPage();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
pageIndex,
|
|
643
|
+
pageCount: totalCount === 0 ? 0 : pageCount,
|
|
644
|
+
pageSize,
|
|
645
|
+
pageSizeOptions,
|
|
646
|
+
hasNextPage,
|
|
647
|
+
hasPreviousPage,
|
|
648
|
+
totalCount,
|
|
649
|
+
goToNextPage,
|
|
650
|
+
goToPreviousPage,
|
|
651
|
+
// Cursors can't seek to an arbitrary offset — honour adjacent pages only.
|
|
652
|
+
goToPage: (next: number) => {
|
|
653
|
+
if (next === pageIndex + 1 && hasNextPage) goToNextPage();
|
|
654
|
+
else if (next === pageIndex - 1 && hasPreviousPage) goToPreviousPage();
|
|
655
|
+
},
|
|
656
|
+
setPageSize: (size: number) => inScopeSources.forEach((c) => c.pagination.setPageSize(size)),
|
|
657
|
+
};
|
|
658
|
+
}, [inScopeSources, totalCount, pagination]);
|
|
659
|
+
|
|
660
|
+
const perSourceResults = useMemo<MergedResultItem[]>(() => {
|
|
661
|
+
const globalPageIndex = perSourceGlobalPagination.pageIndex;
|
|
662
|
+
return concatenatedNodes.filter((item) => {
|
|
663
|
+
const c = controllers[item.sourceKey];
|
|
664
|
+
// Only sources caught up to the current global page contribute; a
|
|
665
|
+
// left-behind source already showed its final page earlier.
|
|
666
|
+
return c?.pagination.pageIndex === globalPageIndex;
|
|
667
|
+
});
|
|
668
|
+
}, [concatenatedNodes, controllers, perSourceGlobalPagination.pageIndex]);
|
|
669
|
+
|
|
670
|
+
const globalPagination = mergedMode ? mergedGlobalPagination : perSourceGlobalPagination;
|
|
671
|
+
const mergedResults = mergedMode ? mergedModeResults : perSourceResults;
|
|
672
|
+
|
|
428
673
|
return useMemo(
|
|
429
674
|
() => ({
|
|
430
675
|
q,
|
|
@@ -433,10 +678,26 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
|
|
|
433
678
|
setScope,
|
|
434
679
|
scopeLocked,
|
|
435
680
|
sources: controllers,
|
|
681
|
+
inScopeSources,
|
|
682
|
+
mergedResults,
|
|
683
|
+
globalPagination,
|
|
436
684
|
loading,
|
|
437
685
|
error,
|
|
438
686
|
resetAll,
|
|
439
687
|
}),
|
|
440
|
-
[
|
|
688
|
+
[
|
|
689
|
+
q,
|
|
690
|
+
setQ,
|
|
691
|
+
scope,
|
|
692
|
+
setScope,
|
|
693
|
+
scopeLocked,
|
|
694
|
+
controllers,
|
|
695
|
+
inScopeSources,
|
|
696
|
+
mergedResults,
|
|
697
|
+
globalPagination,
|
|
698
|
+
loading,
|
|
699
|
+
error,
|
|
700
|
+
resetAll,
|
|
701
|
+
],
|
|
441
702
|
);
|
|
442
703
|
}
|
package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ export { useDistinctValues } from "./hooks/useDistinctValues";
|
|
|
23
23
|
|
|
24
24
|
export { SearchBar } from "./components/controls/SearchBar";
|
|
25
25
|
export { SearchResults } from "./components/SearchResults";
|
|
26
|
+
export { MergedSearchResults } from "./components/MergedSearchResults";
|
|
27
|
+
export type { MergedSearchResultsProps } from "./components/MergedSearchResults";
|
|
26
28
|
export { SourceSection } from "./components/SourceSection";
|
|
27
29
|
export { SortControl } from "./components/controls/SortControl";
|
|
28
30
|
export { PaginationControls } from "./components/controls/PaginationControls";
|
|
@@ -53,7 +55,12 @@ export {
|
|
|
53
55
|
writeSourceParams,
|
|
54
56
|
GLOBAL_QUERY_KEY,
|
|
55
57
|
} from "./utils/filterUtils";
|
|
56
|
-
export type {
|
|
58
|
+
export type {
|
|
59
|
+
ActiveFilterValue,
|
|
60
|
+
DateFilterMode,
|
|
61
|
+
FilterFieldConfig,
|
|
62
|
+
FilterFieldType,
|
|
63
|
+
} from "./utils/filterUtils";
|
|
57
64
|
export { buildOrderBy } from "./utils/sortUtils";
|
|
58
65
|
export type { SortFieldConfig, SortState } from "./utils/sortUtils";
|
|
59
66
|
|
|
@@ -71,10 +78,13 @@ export type {
|
|
|
71
78
|
DisplayField,
|
|
72
79
|
SObjectSourceConfig,
|
|
73
80
|
SearchConfig,
|
|
81
|
+
SearchPaginationConfig,
|
|
74
82
|
SourceController,
|
|
75
83
|
SourceResult,
|
|
76
84
|
SourcePageInfo,
|
|
77
85
|
SearchHandle,
|
|
78
86
|
SearchScope,
|
|
79
87
|
RenderResultFn,
|
|
88
|
+
GlobalPagination,
|
|
89
|
+
MergedResultItem,
|
|
80
90
|
} from "./types";
|
|
@@ -54,7 +54,7 @@ function buildSourceWhere(
|
|
|
54
54
|
const clauses: unknown[] = [];
|
|
55
55
|
const globalClause = buildGlobalQueryClause(q, source.searchableFields);
|
|
56
56
|
if (globalClause) clauses.push(globalClause);
|
|
57
|
-
const structured = buildFilter(filters, source.
|
|
57
|
+
const structured = buildFilter(filters, source.filterBy ?? []);
|
|
58
58
|
if (structured) clauses.push(structured);
|
|
59
59
|
if (clauses.length === 0) return undefined;
|
|
60
60
|
if (clauses.length === 1) return clauses[0];
|