@salesforce/ui-bundle-template-feature-react-search 11.10.1 → 11.11.1

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 (35) hide show
  1. package/README.md +435 -93
  2. package/dist/CHANGELOG.md +16 -0
  3. package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
  4. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
  5. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
  6. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
  7. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
  8. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
  9. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
  10. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
  11. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
  12. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
  13. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
  14. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
  15. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
  16. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
  17. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
  18. package/dist/package-lock.json +2 -2
  19. package/dist/package.json +1 -1
  20. package/package.json +2 -2
  21. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__popover.tsx +40 -0
  22. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
  23. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
  24. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
  25. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
  26. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
  27. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
  28. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
  29. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
  30. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
  31. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
  32. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
  33. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
  34. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
  35. 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
- function defaultPageSize(source: SObjectSourceConfig): number {
56
- return source.pageSize ?? source.validPageSizes?.[0] ?? 10;
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
- function validatePageSize(source: SObjectSourceConfig, size: number): number {
60
- const valid = source.validPageSizes;
61
- if (!valid || valid.length === 0) return size;
62
- return valid.includes(size) ? size : defaultPageSize(source);
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(source: SObjectSourceConfig, params: URLSearchParams): SourceLocalState {
66
- const read = readSourceParams(params, source.key, source.filterFields ?? []);
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(source, read.pageSize ?? defaultPageSize(source));
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 this
152
- // source's defaults, so a reset (or untouched source) leaves
153
- // a clean query string instead of echoing the defaults.
154
- { sort: source.defaultSort ?? null, pageSize: defaultPageSize(source) },
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: defaultPageSize(source),
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
- p: st?.pageSize,
272
- a: st?.afterCursor ?? null,
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(source, size) }),
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 ?? defaultPageSize(source),
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
- [q, setQ, scope, setScope, scopeLocked, controllers, loading, error, resetAll],
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
  }
@@ -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 { ActiveFilterValue, FilterFieldConfig, FilterFieldType } from "./utils/filterUtils";
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.filterFields ?? []);
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];