@salesforce/ui-bundle-template-app-react-template-b2x 11.17.1 → 11.19.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.
Files changed (77) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/reactexternalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/{features/object-search/components → components}/ObjectBreadcrumb.tsx +7 -2
  4. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/api/distinctValuesService.ts +84 -0
  5. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/api/searchService.ts +71 -0
  6. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/MergedSearchResults.tsx +263 -0
  7. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/Search.tsx +185 -0
  8. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/SearchResults.tsx +77 -0
  9. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/SourceSection.tsx +166 -0
  10. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/PaginationControls.tsx +185 -0
  11. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  12. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/SearchBar.tsx +55 -0
  13. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/controls/SortControl.tsx +62 -0
  14. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  15. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/DefaultFilterPanel.tsx +125 -0
  16. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/{object-search/components → search/components/filters}/FilterContext.tsx +7 -20
  17. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  18. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/DateRangeFilter.tsx +160 -0
  19. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  20. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +88 -0
  21. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  22. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  23. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  24. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  25. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/config.json +83 -0
  26. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/{object-search → search}/hooks/useAsyncData.ts +3 -10
  27. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/hooks/useDistinctValues.ts +59 -0
  28. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/hooks/useSearch.ts +703 -0
  29. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/index.ts +90 -0
  30. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/loadConfig.ts +14 -0
  31. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/queryBuilder.ts +156 -0
  32. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/types.ts +307 -0
  33. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/debounce.ts +17 -0
  34. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/fieldUtils.ts +14 -0
  35. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/filterUtils.ts +285 -0
  36. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/search/utils/sortUtils.ts +34 -0
  37. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountObjectDetailPage.tsx +49 -16
  38. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountSearch.tsx +21 -293
  39. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/Home.tsx +22 -7
  40. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/routes.tsx +1 -1
  41. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/{features/object-search/utils/fieldUtils.ts → utils/accountFields.ts} +9 -0
  42. package/dist/force-app/main/default/uiBundles/reactexternalapp/tsconfig.tsbuildinfo +1 -1
  43. package/dist/package-lock.json +2 -2
  44. package/dist/package.json +1 -1
  45. package/package.json +1 -1
  46. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/accountSearchService.ts +0 -46
  47. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/distinctAccountIndustries.graphql +0 -19
  48. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/distinctAccountTypes.graphql +0 -19
  49. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/account/query/searchAccounts.graphql +0 -51
  50. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/api/graphql-operations-types.ts +0 -11260
  51. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/accountSearchService.ts +0 -46
  52. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/distinctAccountIndustries.graphql +0 -19
  53. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/distinctAccountTypes.graphql +0 -19
  54. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/getAccountDetail.graphql +0 -121
  55. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/api/query/searchAccounts.graphql +0 -51
  56. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +0 -355
  57. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +0 -304
  58. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/Home.tsx +0 -34
  59. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/api/objectSearchService.ts +0 -82
  60. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/ActiveFilters.tsx +0 -89
  61. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/PaginationControls.tsx +0 -109
  62. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/SearchBar.tsx +0 -41
  63. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/SortControl.tsx +0 -143
  64. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/BooleanFilter.tsx +0 -78
  65. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/DateFilter.tsx +0 -128
  66. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/DateRangeFilter.tsx +0 -70
  67. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +0 -43
  68. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +0 -105
  69. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +0 -171
  70. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SearchFilter.tsx +0 -52
  71. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SelectFilter.tsx +0 -97
  72. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/TextFilter.tsx +0 -93
  73. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useDebouncedCallback.ts +0 -34
  74. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useObjectSearchParams.ts +0 -254
  75. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/debounce.ts +0 -25
  76. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/filterUtils.ts +0 -404
  77. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/utils/sortUtils.ts +0 -38
@@ -0,0 +1,703 @@
1
+ /**
2
+ * useSearch — orchestrates configuration-driven multi-source search.
3
+ *
4
+ * Owns:
5
+ * - global `q` (one search box drives all sources)
6
+ * - per-source filters / sort / pagination (independent state per source)
7
+ * - URL sync (debounced; namespace `s.<key>.f.<field>=...`)
8
+ * - one batched GraphQL fetch on every state change
9
+ *
10
+ * Returns a per-source `controller` map plus aggregate loading/error flags.
11
+ * Rendering and routing are the caller's responsibility.
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { useSearchParams } from "react-router";
16
+ import { runSearch } from "../api/searchService";
17
+ import {
18
+ GLOBAL_QUERY_KEY,
19
+ readSourceParams,
20
+ writeSourceParams,
21
+ type ActiveFilterValue,
22
+ } from "../utils/filterUtils";
23
+ import type { SortState } from "../utils/sortUtils";
24
+ import { debounce } from "../utils/debounce";
25
+ import type {
26
+ SObjectSourceConfig,
27
+ SourceController,
28
+ SourceResult,
29
+ SearchConfig,
30
+ SearchHandle,
31
+ SearchScope,
32
+ GlobalPagination,
33
+ MergedResultItem,
34
+ } from "../types";
35
+ import { ALL_SCOPE } from "../types";
36
+
37
+ const URL_SYNC_DEBOUNCE_MS = 300;
38
+ const SCOPE_KEY = "scope";
39
+
40
+ /**
41
+ * Returns true when `source` should participate in the current scope —
42
+ * either the scope is "all" or the scope matches this source's key.
43
+ */
44
+ function isSourceInScope(scope: SearchScope, sourceKey: string): boolean {
45
+ return scope === ALL_SCOPE || scope === sourceKey;
46
+ }
47
+
48
+ interface SourceLocalState {
49
+ filters: ActiveFilterValue[];
50
+ sort: SortState | null;
51
+ pageSize: number;
52
+ pageIndex: number;
53
+ afterCursor: string | undefined;
54
+ cursorStack: string[];
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
+ };
81
+ }
82
+
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;
88
+ }
89
+
90
+ function initSourceState(
91
+ source: SObjectSourceConfig,
92
+ params: URLSearchParams,
93
+ pagination: ResolvedPagination,
94
+ ): SourceLocalState {
95
+ const read = readSourceParams(params, source.key, source.filterBy ?? []);
96
+ const sort = read.sort ?? source.defaultSort ?? null;
97
+ const pageSize = validatePageSize(pagination, read.pageSize ?? pagination.pageSize);
98
+ return {
99
+ filters: read.filters,
100
+ sort,
101
+ pageSize,
102
+ pageIndex: read.pageIndex,
103
+ afterCursor: undefined,
104
+ cursorStack: [],
105
+ };
106
+ }
107
+
108
+ export interface UseSearchOptions {
109
+ /**
110
+ * Lock the search to a single source key (or "all"). When set:
111
+ * - `scope` is forced to this value and `setScope` becomes a no-op.
112
+ * - The URL never writes `?scope=` (it's implicit in the page route).
113
+ * - The dropdown should be hidden by the caller.
114
+ *
115
+ * Use when you embed the search inside a page that already represents
116
+ * the source (e.g. `/accounts/search` rendering only Accounts).
117
+ */
118
+ lockedScope?: SearchScope;
119
+ }
120
+
121
+ export function useSearch(config: SearchConfig, options?: UseSearchOptions): SearchHandle {
122
+ const [searchParams, setSearchParams] = useSearchParams();
123
+ const lockedScope = options?.lockedScope;
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
+
135
+ // One-time seed from URL.
136
+ const initialSourceStates = useMemo(() => {
137
+ const map: Record<string, SourceLocalState> = {};
138
+ for (const source of config.sources) {
139
+ map[source.key] = initSourceState(source, searchParams, pagination);
140
+ }
141
+ return map;
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, []);
144
+ const initialQ = useMemo(() => searchParams.get(GLOBAL_QUERY_KEY) ?? "", []);
145
+ const initialScope = useMemo<SearchScope>(() => {
146
+ // When the caller locks the scope, that always wins over the URL.
147
+ if (lockedScope !== undefined) {
148
+ if (lockedScope === ALL_SCOPE) return ALL_SCOPE;
149
+ return config.sources.some((s) => s.key === lockedScope) ? lockedScope : ALL_SCOPE;
150
+ }
151
+ const raw = searchParams.get(SCOPE_KEY);
152
+ if (!raw || raw === ALL_SCOPE) return ALL_SCOPE;
153
+ return config.sources.some((s) => s.key === raw) ? raw : ALL_SCOPE;
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
155
+ }, []);
156
+
157
+ const [q, setQState] = useState(initialQ);
158
+ const [scope, setScopeState] = useState<SearchScope>(initialScope);
159
+ const [sourceStates, setSourceStates] =
160
+ useState<Record<string, SourceLocalState>>(initialSourceStates);
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
+
172
+ // Snapshot ref so debounced URL sync sees the latest state without
173
+ // recreating callbacks on every render.
174
+ const stateRef = useRef({ q, scope, sourceStates });
175
+ useEffect(() => {
176
+ stateRef.current = { q, scope, sourceStates };
177
+ });
178
+
179
+ // -- URL sync --------------------------------------------------------------
180
+
181
+ const syncToUrl = useCallback(
182
+ (nextQ: string, nextScope: SearchScope, nextStates: Record<string, SourceLocalState>) => {
183
+ const params = new URLSearchParams();
184
+ if (nextQ) params.set(GLOBAL_QUERY_KEY, nextQ);
185
+ // Skip writing ?scope when caller has locked the scope — it's
186
+ // already implicit in the page route.
187
+ if (lockedScope === undefined && nextScope !== ALL_SCOPE) {
188
+ params.set(SCOPE_KEY, nextScope);
189
+ }
190
+ for (const source of config.sources) {
191
+ const state = nextStates[source.key];
192
+ if (!state) continue;
193
+ writeSourceParams(
194
+ params,
195
+ source.key,
196
+ state.filters,
197
+ state.sort,
198
+ state.pageSize,
199
+ state.pageIndex,
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 },
204
+ );
205
+ }
206
+ setSearchParams(params, { replace: true });
207
+ },
208
+ [config.sources, setSearchParams, lockedScope, pagination.pageSize],
209
+ );
210
+
211
+ const debouncedSyncRef = useRef(debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS));
212
+ useEffect(() => {
213
+ debouncedSyncRef.current = debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS);
214
+ }, [syncToUrl]);
215
+
216
+ // Update one source's state and (optionally) reset its pagination.
217
+ const updateSource = useCallback(
218
+ (
219
+ sourceKey: string,
220
+ updater: (prev: SourceLocalState) => SourceLocalState,
221
+ resetPagination: boolean,
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);
226
+ setSourceStates((prev) => {
227
+ const prior = prev[sourceKey];
228
+ if (!prior) return prev;
229
+ let next = updater(prior);
230
+ if (resetPagination) {
231
+ next = { ...next, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
232
+ }
233
+ const merged = { ...prev, [sourceKey]: next };
234
+ debouncedSyncRef.current(stateRef.current.q, stateRef.current.scope, merged);
235
+ return merged;
236
+ });
237
+ },
238
+ [],
239
+ );
240
+
241
+ // -- Global q --------------------------------------------------------------
242
+
243
+ const setQ = useCallback((nextQ: string) => {
244
+ setQState(nextQ);
245
+ // A new term changes the whole result set — back to the first page.
246
+ setGlobalPageIndex(0);
247
+ // Changing the global term invalidates every cursor.
248
+ setSourceStates((prev) => {
249
+ const next: Record<string, SourceLocalState> = {};
250
+ for (const [key, state] of Object.entries(prev)) {
251
+ next[key] = { ...state, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
252
+ }
253
+ debouncedSyncRef.current(nextQ, stateRef.current.scope, next);
254
+ return next;
255
+ });
256
+ }, []);
257
+
258
+ // -- Scope -----------------------------------------------------------------
259
+
260
+ const setScope = useCallback(
261
+ (nextScope: SearchScope) => {
262
+ // Locked scope wins over user input — silently no-op.
263
+ if (lockedScope !== undefined) return;
264
+ // Reject unknown scope values; fall back to "all".
265
+ const validated: SearchScope =
266
+ nextScope === ALL_SCOPE || config.sources.some((s) => s.key === nextScope)
267
+ ? nextScope
268
+ : ALL_SCOPE;
269
+ setScopeState(validated);
270
+ // The visible set changes — reset the global page too.
271
+ setGlobalPageIndex(0);
272
+ // Narrowing or widening invalidates every cursor — the visible result
273
+ // set is changing.
274
+ setSourceStates((prev) => {
275
+ const next: Record<string, SourceLocalState> = {};
276
+ for (const [key, state] of Object.entries(prev)) {
277
+ next[key] = { ...state, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
278
+ }
279
+ debouncedSyncRef.current(stateRef.current.q, validated, next);
280
+ return next;
281
+ });
282
+ },
283
+ [config.sources, lockedScope],
284
+ );
285
+
286
+ // -- Reset all -------------------------------------------------------------
287
+
288
+ const resetAll = useCallback(() => {
289
+ setQState("");
290
+ setGlobalPageIndex(0);
291
+ // Preserve locked scope; otherwise widen back to "all".
292
+ const nextScope: SearchScope = lockedScope ?? ALL_SCOPE;
293
+ setScopeState(nextScope);
294
+ const empty: Record<string, SourceLocalState> = {};
295
+ for (const source of config.sources) {
296
+ empty[source.key] = {
297
+ filters: [],
298
+ sort: source.defaultSort ?? null,
299
+ pageSize: pagination.pageSize,
300
+ pageIndex: 0,
301
+ afterCursor: undefined,
302
+ cursorStack: [],
303
+ };
304
+ }
305
+ setSourceStates(empty);
306
+ setGlobalPageSize(pagination.pageSize);
307
+ syncToUrl("", nextScope, empty);
308
+ }, [config.sources, syncToUrl, lockedScope, pagination.pageSize]);
309
+
310
+ // -- Fetch -----------------------------------------------------------------
311
+
312
+ const requestKey = useMemo(() => {
313
+ // Stable string key that changes only when something fetch-relevant changes.
314
+ // Sources outside the current scope are listed (with `skip: true`) so
315
+ // the key still changes when scope toggles, but they don't contribute
316
+ // per-source state to the diff.
317
+ return JSON.stringify({
318
+ q,
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,
324
+ s: config.sources.map((source) => {
325
+ if (!isSourceInScope(scope, source.key)) {
326
+ return { k: source.key, skip: true };
327
+ }
328
+ const st = sourceStates[source.key];
329
+ return {
330
+ k: source.key,
331
+ f: st?.filters,
332
+ o: st?.sort,
333
+ // In merged mode per-source size/cursor are overridden below.
334
+ p: mergedMode ? undefined : st?.pageSize,
335
+ a: mergedMode ? undefined : (st?.afterCursor ?? null),
336
+ };
337
+ }),
338
+ });
339
+ }, [q, scope, sourceStates, config.sources, mergedMode, globalPageIndex, globalPageSize]);
340
+
341
+ const [results, setResults] = useState<Record<string, SourceResult>>({});
342
+ const [loading, setLoading] = useState(true);
343
+ const [error, setError] = useState<string | null>(null);
344
+ const fetchGenRef = useRef(0);
345
+
346
+ useEffect(() => {
347
+ const generation = ++fetchGenRef.current;
348
+ setLoading(true);
349
+ setError(null);
350
+
351
+ const activeSources = config.sources.filter((source) => isSourceInScope(scope, source.key));
352
+
353
+ // All sources gated out — clear results and exit without firing a request.
354
+ if (activeSources.length === 0) {
355
+ setResults({});
356
+ setLoading(false);
357
+ return;
358
+ }
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
+
367
+ const requests = activeSources.map((source) => {
368
+ const st = sourceStates[source.key]!;
369
+ return {
370
+ source,
371
+ q,
372
+ filters: st.filters,
373
+ sort: st.sort,
374
+ pageSize: mergedMode ? mergedFetchSize : st.pageSize,
375
+ afterCursor: mergedMode ? undefined : st.afterCursor,
376
+ };
377
+ });
378
+
379
+ runSearch(requests)
380
+ .then((resp) => {
381
+ if (generation !== fetchGenRef.current) return;
382
+ setResults(resp);
383
+ })
384
+ .catch((err: unknown) => {
385
+ if (generation !== fetchGenRef.current) return;
386
+ console.error(err);
387
+ setError(err instanceof Error ? err.message : "Unified search failed");
388
+ })
389
+ .finally(() => {
390
+ if (generation !== fetchGenRef.current) return;
391
+ setLoading(false);
392
+ });
393
+ // requestKey is the dependency-of-record; sourceStates and q are
394
+ // captured via the ref-like closure above each render.
395
+ // eslint-disable-next-line react-hooks/exhaustive-deps
396
+ }, [requestKey]);
397
+
398
+ // -- Per-source controllers ------------------------------------------------
399
+
400
+ const controllers = useMemo(() => {
401
+ const out: Record<string, SourceController> = {};
402
+ for (const source of config.sources) {
403
+ const state = sourceStates[source.key];
404
+ const result = results[source.key] ?? null;
405
+
406
+ const setFilter = (field: string, value: ActiveFilterValue | undefined) => {
407
+ updateSource(
408
+ source.key,
409
+ (prev) => {
410
+ const filtered = prev.filters.filter((f) => f.field !== field);
411
+ if (value) filtered.push(value);
412
+ return { ...prev, filters: filtered };
413
+ },
414
+ true,
415
+ );
416
+ };
417
+ const removeFilter = (field: string) => {
418
+ updateSource(
419
+ source.key,
420
+ (prev) => ({
421
+ ...prev,
422
+ filters: prev.filters.filter((f) => f.field !== field),
423
+ }),
424
+ true,
425
+ );
426
+ };
427
+ const setSort = (sort: SortState | null) => {
428
+ updateSource(source.key, (prev) => ({ ...prev, sort }), true);
429
+ };
430
+ const setPageSize = (size: number) => {
431
+ updateSource(
432
+ source.key,
433
+ (prev) => ({ ...prev, pageSize: validatePageSize(pagination, size) }),
434
+ true,
435
+ );
436
+ };
437
+ const goToNextPage = () => {
438
+ const cursor = result?.pageInfo?.endCursor;
439
+ if (!cursor) return;
440
+ updateSource(
441
+ source.key,
442
+ (prev) => ({
443
+ ...prev,
444
+ cursorStack: [...prev.cursorStack, cursor],
445
+ afterCursor: cursor,
446
+ pageIndex: prev.pageIndex + 1,
447
+ }),
448
+ false,
449
+ );
450
+ };
451
+ const goToPreviousPage = () => {
452
+ updateSource(
453
+ source.key,
454
+ (prev) => {
455
+ if (prev.pageIndex === 0) return prev;
456
+ const nextStack = prev.cursorStack.slice(0, -1);
457
+ return {
458
+ ...prev,
459
+ cursorStack: nextStack,
460
+ afterCursor: nextStack[nextStack.length - 1],
461
+ pageIndex: Math.max(0, prev.pageIndex - 1),
462
+ };
463
+ },
464
+ false,
465
+ );
466
+ };
467
+
468
+ out[source.key] = {
469
+ config: source,
470
+ result,
471
+ loading,
472
+ error,
473
+ filters: {
474
+ active: state?.filters ?? [],
475
+ set: setFilter,
476
+ remove: removeFilter,
477
+ },
478
+ sort: {
479
+ current: state?.sort ?? null,
480
+ set: setSort,
481
+ },
482
+ pagination: {
483
+ pageSize: state?.pageSize ?? pagination.pageSize,
484
+ pageSizeOptions: pagination.pageSizeOptions,
485
+ pageIndex: state?.pageIndex ?? 0,
486
+ hasNextPage: result?.pageInfo?.hasNextPage ?? false,
487
+ hasPreviousPage: (state?.pageIndex ?? 0) > 0,
488
+ setPageSize,
489
+ goToNextPage,
490
+ goToPreviousPage,
491
+ },
492
+ };
493
+ }
494
+ return out;
495
+ }, [config.sources, sourceStates, results, loading, error, updateSource, pagination]);
496
+
497
+ const scopeLocked = lockedScope !== undefined;
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
+
673
+ return useMemo(
674
+ () => ({
675
+ q,
676
+ setQ,
677
+ scope,
678
+ setScope,
679
+ scopeLocked,
680
+ sources: controllers,
681
+ inScopeSources,
682
+ mergedResults,
683
+ globalPagination,
684
+ loading,
685
+ error,
686
+ resetAll,
687
+ }),
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
+ ],
702
+ );
703
+ }