@salesforce/ui-bundle-template-app-react-template-b2e 11.55.0 → 11.56.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 (42) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/reactinternalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  5. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  6. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  7. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  8. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  9. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  10. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  11. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  12. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/index.ts +54 -0
  13. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  14. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  15. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/types.ts +66 -0
  16. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/registry.ts +38 -0
  17. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/index.ts +19 -0
  18. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  19. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  20. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts +101 -0
  21. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/api/searchService.ts +110 -45
  22. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  23. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  24. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/Search.tsx +31 -13
  25. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SearchResults.tsx +17 -11
  26. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SourceSection.tsx +9 -4
  27. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  28. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  29. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  30. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json +7 -2
  31. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts +8 -0
  32. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/hooks/useSearch.ts +313 -50
  33. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/index.ts +26 -1
  34. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/queryBuilder.ts +62 -118
  35. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/types.ts +74 -5
  36. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/Home.tsx +3 -42
  37. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/routes.tsx +12 -5
  38. package/dist/force-app/main/default/uiBundles/reactinternalapp/tsconfig.tsbuildinfo +1 -1
  39. package/dist/package-lock.json +2 -2
  40. package/dist/package.json +1 -1
  41. package/package.json +4 -2
  42. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/AccountSearch.tsx +0 -25
@@ -23,7 +23,7 @@ import {
23
23
  import type { SortState } from "../utils/sortUtils";
24
24
  import { debounce } from "../utils/debounce";
25
25
  import type {
26
- SObjectSourceConfig,
26
+ SourceConfig,
27
27
  SourceController,
28
28
  SourceResult,
29
29
  SearchConfig,
@@ -32,17 +32,49 @@ import type {
32
32
  GlobalPagination,
33
33
  MergedResultItem,
34
34
  } from "../types";
35
- import { ALL_SCOPE } from "../types";
35
+ import { ALL_SCOPE, parseScope } from "../types";
36
+ import type { SourceRequest } from "../adapters/types";
37
+ import { MIN_QUERY_LENGTH } from "../constants";
38
+ // `getUIBundleId` supplies the search-query UIBundle id (the isolated swap
39
+ // point) and is resolved by this caller BEFORE the sync query builder runs;
40
+ // `resolveChannelId` derives the discovery channel id from a CMS result;
41
+ // `useSearchableContentTypes` is the session-cached discovery hook;
42
+ // `isValidCmsFqn` validates a selected FQN before it enters state or a query.
43
+ import { getUIBundleId, isConfiguredUIBundleId } from "../adapters/cms/searchChannel";
44
+ import { getOrgSupportsCmsSearch } from "../adapters/cms/api/orgApiVersionService";
45
+ import { resolveChannelId } from "../adapters/cms/channelResolver";
46
+ import { useSearchableContentTypes } from "../adapters/cms/hooks/useSearchableContentTypes";
47
+ import { isValidCmsFqn } from "../adapters/cms/contentTypeUtils";
48
+ import { readLastChannelId, writeLastChannelId } from "../adapters/cms/contentTypeSessionCache";
36
49
 
37
50
  const URL_SYNC_DEBOUNCE_MS = 300;
38
- const SCOPE_KEY = "scope";
39
51
 
40
52
  /**
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.
53
+ * Returns true when `source` should participate in the current scope. Only the
54
+ * scope's `base` (`"all"` or a source key) decides membership — a
55
+ * `"<cmsKey>:<fqn>"` scope has `base === <cmsKey>`, so it naturally includes ONLY
56
+ * that CMS source and drops every SObject source (CMS-only search).
43
57
  */
44
58
  function isSourceInScope(scope: SearchScope, sourceKey: string): boolean {
45
- return scope === ALL_SCOPE || scope === sourceKey;
59
+ const { base } = parseScope(scope);
60
+ return base === ALL_SCOPE || base === sourceKey;
61
+ }
62
+
63
+ /**
64
+ * Validates a raw scope string against the config. Accepts `"all"`, any source
65
+ * key, and the `"<cmsKey>:<fqn>"` form (base must be a CMS source and the FQN
66
+ * must pass `isValidCmsFqn`). Returns a safe scope, falling back to `"all"`.
67
+ */
68
+ function validateScope(config: SearchConfig, raw: string): SearchScope {
69
+ if (raw === ALL_SCOPE) return ALL_SCOPE;
70
+ const { base, cmsFqn } = parseScope(raw);
71
+ const source = config.sources.find((s) => s.key === base);
72
+ if (!source) return ALL_SCOPE;
73
+ if (cmsFqn !== undefined) {
74
+ // A content-type scope is only valid on a CMS source with a well-formed FQN.
75
+ return source.kind === "cms" && isValidCmsFqn(cmsFqn) ? raw : ALL_SCOPE;
76
+ }
77
+ return raw;
46
78
  }
47
79
 
48
80
  interface SourceLocalState {
@@ -88,12 +120,16 @@ function validatePageSize(pagination: ResolvedPagination, size: number): number
88
120
  }
89
121
 
90
122
  function initSourceState(
91
- source: SObjectSourceConfig,
123
+ source: SourceConfig,
92
124
  params: URLSearchParams,
93
125
  pagination: ResolvedPagination,
94
126
  ): SourceLocalState {
95
- const read = readSourceParams(params, source.key, source.filterBy ?? []);
96
- const sort = read.sort ?? source.defaultSort ?? null;
127
+ // `filterBy` / `defaultSort` are SObject-only — a CMS source has neither, so
128
+ // it seeds with no filters and no default sort.
129
+ const filterBy = source.kind === "sobject" ? (source.filterBy ?? []) : [];
130
+ const defaultSort = source.kind === "sobject" ? (source.defaultSort ?? null) : null;
131
+ const read = readSourceParams(params, source.key, filterBy);
132
+ const sort = read.sort ?? defaultSort;
97
133
  const pageSize = validatePageSize(pagination, read.pageSize ?? pagination.pageSize);
98
134
  return {
99
135
  filters: read.filters,
@@ -143,16 +179,19 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
143
179
  }, []);
144
180
  const initialQ = useMemo(() => searchParams.get(GLOBAL_QUERY_KEY) ?? "", []);
145
181
  const initialScope = useMemo<SearchScope>(() => {
146
- // When the caller locks the scope, that always wins over the URL.
182
+ // When the caller locks the scope, that always wins. A locked scope is
183
+ // always a plain source key (or "all") — never a content-type form.
147
184
  if (lockedScope !== undefined) {
148
185
  if (lockedScope === ALL_SCOPE) return ALL_SCOPE;
149
186
  return config.sources.some((s) => s.key === lockedScope) ? lockedScope : ALL_SCOPE;
150
187
  }
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
- }, []);
188
+ // A fresh load always starts in "All" scope. We deliberately do NOT restore
189
+ // a persisted `?scope=` — a narrowed scope (especially a CMS content-type
190
+ // scope) depends on runtime-discovered content types that aren't known yet
191
+ // on first paint, so restoring it would show a blank/orphaned selection.
192
+ // Starting at "All" re-runs the full search, which re-drives discovery.
193
+ return ALL_SCOPE;
194
+ }, [lockedScope, config.sources]);
156
195
 
157
196
  const [q, setQState] = useState(initialQ);
158
197
  const [scope, setScopeState] = useState<SearchScope>(initialScope);
@@ -182,11 +221,11 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
182
221
  (nextQ: string, nextScope: SearchScope, nextStates: Record<string, SourceLocalState>) => {
183
222
  const params = new URLSearchParams();
184
223
  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
- }
224
+ // Intentionally NOT persisting `?scope=`: a fresh load always starts in
225
+ // "All" (see `initialScope`), so writing the scope would leave a stale
226
+ // param the next load ignores. `nextScope` is kept in the signature for
227
+ // call-site compatibility.
228
+ void nextScope;
190
229
  for (const source of config.sources) {
191
230
  const state = nextStates[source.key];
192
231
  if (!state) continue;
@@ -199,8 +238,12 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
199
238
  state.pageIndex,
200
239
  // Omit sort / page size from the URL when they equal the
201
240
  // 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 },
241
+ // query string instead of echoing the defaults. `defaultSort` is
242
+ // SObject-only (CMS has none).
243
+ {
244
+ sort: source.kind === "sobject" ? (source.defaultSort ?? null) : null,
245
+ pageSize: pagination.pageSize,
246
+ },
204
247
  );
205
248
  }
206
249
  setSearchParams(params, { replace: true });
@@ -261,11 +304,9 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
261
304
  (nextScope: SearchScope) => {
262
305
  // Locked scope wins over user input — silently no-op.
263
306
  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;
307
+ // Reject unknown scope values; fall back to "all". Accepts a plain
308
+ // source key, "all", or the CMS-only "<cmsKey>:<fqn>" form.
309
+ const validated: SearchScope = validateScope(config, nextScope);
269
310
  setScopeState(validated);
270
311
  // The visible set changes — reset the global page too.
271
312
  setGlobalPageIndex(0);
@@ -280,7 +321,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
280
321
  return next;
281
322
  });
282
323
  },
283
- [config.sources, lockedScope],
324
+ [config, lockedScope],
284
325
  );
285
326
 
286
327
  // -- Reset all -------------------------------------------------------------
@@ -295,7 +336,8 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
295
336
  for (const source of config.sources) {
296
337
  empty[source.key] = {
297
338
  filters: [],
298
- sort: source.defaultSort ?? null,
339
+ // `defaultSort` is SObject-only; CMS resets to no sort.
340
+ sort: source.kind === "sobject" ? (source.defaultSort ?? null) : null,
299
341
  pageSize: pagination.pageSize,
300
342
  pageIndex: 0,
301
343
  afterCursor: undefined,
@@ -309,6 +351,48 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
309
351
 
310
352
  // -- Fetch -----------------------------------------------------------------
311
353
 
354
+ // -- CMS content-type discovery --------------------------------------------
355
+ //
356
+ // The channel id used to DISCOVER content types is derived FROM a CMS search
357
+ // result (the permanent path — distinct from the temporary `getUIBundleId()`
358
+ // fed INTO the query). After a CMS result arrives, the fetch effect resolves
359
+ // it via `resolveChannelId` and stores it here; the session-cached hook then
360
+ // fetches the searchable content types ONCE per channel. The discovered FQNs
361
+ // (a) refine every subsequent all/cms-scope request to `contentTypes` and
362
+ // (b) are exposed for the scope dropdown to append per-type entries.
363
+ // Seed from the last discovered channel persisted in sessionStorage so a hard
364
+ // reload rehydrates the content-type scope entries immediately — even when
365
+ // the current scope is a non-CMS source (which would never re-query CMS and
366
+ // thus never re-derive the channel id on its own). The persisted pointer is
367
+ // keyed by UIBundle id, so this seed waits for `getUIBundleId()` (below) —
368
+ // starting null avoids seeding a DIFFERENT bundle's channel/content types.
369
+ const [discoveryChannelId, setDiscoveryChannelId] = useState<string | null>(null);
370
+ // Resolve the current UIBundle id once and seed the discovery channel from
371
+ // THIS bundle's persisted pointer. Keyed lookup prevents a second bundle
372
+ // opened in the same session from inheriting the first bundle's channel.
373
+ useEffect(() => {
374
+ let cancelled = false;
375
+ getUIBundleId()
376
+ .then((uiBundleId) => {
377
+ if (cancelled || !isConfiguredUIBundleId(uiBundleId)) return;
378
+ const seeded = readLastChannelId(uiBundleId);
379
+ if (seeded) setDiscoveryChannelId((prev) => prev ?? seeded);
380
+ })
381
+ .catch(() => {
382
+ // Seeding is best-effort; a CMS result will still drive discovery.
383
+ });
384
+ return () => {
385
+ cancelled = true;
386
+ };
387
+ }, []);
388
+ const { contentTypes: discoveredContentTypes } = useSearchableContentTypes(discoveryChannelId);
389
+ // The query filter and request key work in FQNs only; the `{ fqn, label }`
390
+ // objects are threaded to the handle for the scope dropdown's display labels.
391
+ const discoveredContentTypeFqns = useMemo(
392
+ () => discoveredContentTypes.map((ct) => ct.fqn),
393
+ [discoveredContentTypes],
394
+ );
395
+
312
396
  const requestKey = useMemo(() => {
313
397
  // Stable string key that changes only when something fetch-relevant changes.
314
398
  // Sources outside the current scope are listed (with `skip: true`) so
@@ -316,7 +400,14 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
316
400
  // per-source state to the diff.
317
401
  return JSON.stringify({
318
402
  q,
403
+ // `scope` already carries the selected CMS content-type fqn (the
404
+ // "<cmsKey>:<fqn>" form), so a content-type selection re-fires the fetch
405
+ // with no imperative refetch.
319
406
  scope,
407
+ // Discovered content types are part of the key so that once discovery
408
+ // completes, the effect re-fires and every in-scope CMS request switches
409
+ // from the bootstrap call (no `contentTypes`) to the discovered set.
410
+ ct: discoveredContentTypeFqns,
320
411
  // Merged mode paginates globally: every in-scope source fetches the
321
412
  // same front-anchored window, so the global page/size drive the fetch
322
413
  // rather than per-source cursors.
@@ -336,10 +427,24 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
336
427
  };
337
428
  }),
338
429
  });
339
- }, [q, scope, sourceStates, config.sources, mergedMode, globalPageIndex, globalPageSize]);
430
+ }, [
431
+ q,
432
+ scope,
433
+ discoveredContentTypes,
434
+ sourceStates,
435
+ config.sources,
436
+ mergedMode,
437
+ globalPageIndex,
438
+ globalPageSize,
439
+ ]);
340
440
 
341
441
  const [results, setResults] = useState<Record<string, SourceResult>>({});
442
+ // Per-source errors, keyed by `source.key`. A source that failed while a
443
+ // sibling succeeded surfaces its own message here without blanking others.
444
+ const [sourceErrors, setSourceErrors] = useState<Record<string, string>>({});
342
445
  const [loading, setLoading] = useState(true);
446
+ // Page-level error — set ONLY when EVERY in-scope source failed, or on a
447
+ // whole-request / network failure (`runSearch` throwing).
343
448
  const [error, setError] = useState<string | null>(null);
344
449
  const fetchGenRef = useRef(0);
345
450
 
@@ -347,6 +452,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
347
452
  const generation = ++fetchGenRef.current;
348
453
  setLoading(true);
349
454
  setError(null);
455
+ setSourceErrors({});
350
456
 
351
457
  const activeSources = config.sources.filter((source) => isSourceInScope(scope, source.key));
352
458
 
@@ -357,6 +463,25 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
357
463
  return;
358
464
  }
359
465
 
466
+ // Keyword gate: only fire a search once the user has typed at least
467
+ // MIN_QUERY_LENGTH characters. On landing (or after clearing the box) `q`
468
+ // is empty, so we clear results and exit WITHOUT firing a request — no
469
+ // unfiltered "browse the first page" query, and no CMS bootstrap call
470
+ // until there's a keyword. The minimum matters beyond UX: the SObject and
471
+ // CMS search backends reject terms shorter than MIN_QUERY_LENGTH, which
472
+ // otherwise surfaces as "Search failed for this source." across every
473
+ // source. Gating here keeps the too-short case client-side.
474
+ if (q.trim().length < MIN_QUERY_LENGTH) {
475
+ setResults({});
476
+ setLoading(false);
477
+ return;
478
+ }
479
+
480
+ // A "<cmsKey>:<fqn>" scope narrows to one content type. Because its `base`
481
+ // is the CMS source key, `activeSources` already excludes every SObject
482
+ // source (CMS-only search); the fqn flows through as `contentTypes = [fqn]`.
483
+ const { cmsFqn } = parseScope(scope);
484
+
360
485
  // Merged mode: fetch the whole front-anchored window — (page + 1) * size
361
486
  // rows — from every in-scope source, with no cursor. Concatenating these
362
487
  // and slicing [page*size, (page+1)*size) yields the exact global page,
@@ -364,32 +489,164 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
364
489
  // the true merged prefix regardless of how rows interleave.
365
490
  const mergedFetchSize = (globalPageIndex + 1) * globalPageSize;
366
491
 
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
- });
492
+ // Resolve the async CMS preconditions ONCE, up front, then thread them into
493
+ // each CMS request so the synchronous query builder never resolves a promise:
494
+ // - the UIBundle id (`getUIBundleId`), and
495
+ // - whether the build-time API version carries the CMS search backend
496
+ // (`getOrgSupportsCmsSearch`).
497
+ let cancelled = false;
498
+ // True once this run's request set is a DISCOVERY-driven refetch: an
499
+ // all/cms-scope search that auto-applies the content-type FQNs discovered
500
+ // from a prior successful CMS result (as opposed to a user-selected fqn).
501
+ // Such a refetch only NARROWS an already-successful bootstrap, so if it
502
+ // fails it must not destroy the good bootstrap results or raise a
503
+ // page-level error — content-type narrowing is an enhancement, not a
504
+ // hard requirement. Read in `.catch` below.
505
+ let isDiscoveryRefetch = false;
506
+ Promise.all([getUIBundleId(), getOrgSupportsCmsSearch()])
507
+ .then(([uiBundleId, orgSupportsCmsSearch]) => {
508
+ if (cancelled || generation !== fetchGenRef.current) return;
509
+
510
+ const requests: SourceRequest[] = activeSources.flatMap((source) => {
511
+ const st = sourceStates[source.key]!;
512
+ const base: SourceRequest = {
513
+ source,
514
+ q,
515
+ filters: st.filters,
516
+ sort: st.sort,
517
+ pageSize: mergedMode ? mergedFetchSize : st.pageSize,
518
+ afterCursor: mergedMode ? undefined : st.afterCursor,
519
+ };
520
+ if (source.kind !== "cms") return [base];
521
+ // CMS is gated on (a) a CONFIGURED (9YE-shaped) UIBundle id and
522
+ // (b) a build-time API version with the CMS search backend (v68+,
523
+ // core 264+). Skip silently when either is absent.
524
+ if (!isConfiguredUIBundleId(uiBundleId) || !orgSupportsCmsSearch) {
525
+ return [];
526
+ }
527
+ // CMS request: attach the resolved channel id and content types.
528
+ // - specific fqn selected → exactly that type.
529
+ // - all / cms-key scope → the discovered set if known, else
530
+ // omit `contentTypes` entirely (the bootstrap call).
531
+ const contentTypes = cmsFqn
532
+ ? [cmsFqn]
533
+ : discoveredContentTypeFqns.length > 0
534
+ ? discoveredContentTypeFqns
535
+ : undefined;
536
+ // A non-fqn scope that carries auto-discovered content types is a
537
+ // discovery-driven refetch — the bootstrap CMS call already
538
+ // succeeded (that's how the FQNs were discovered). Flag it so a
539
+ // failure here degrades to the bootstrap results instead of
540
+ // blanking them (see `.catch`).
541
+ if (!cmsFqn && discoveredContentTypeFqns.length > 0) {
542
+ isDiscoveryRefetch = true;
543
+ }
544
+ return [{ ...base, uiBundleId, contentTypes }];
545
+ });
546
+
547
+ // Every in-scope source was gated out (e.g. a CMS-only scope while CMS
548
+ // is not yet configured): nothing to fetch. Clear results and stop,
549
+ // mirroring the "all sources gated out" branch above.
550
+ if (requests.length === 0) {
551
+ setResults({});
552
+ setLoading(false);
553
+ return;
554
+ }
378
555
 
379
- runSearch(requests)
380
- .then((resp) => {
381
- if (generation !== fetchGenRef.current) return;
382
- setResults(resp);
556
+ // The sources actually queried (CMS may have been skipped above). Used
557
+ // for the all-failed check so a skipped/not-configured CMS source is
558
+ // never counted as a failure.
559
+ const requestedSources = requests.map((r) => r.source);
560
+
561
+ return runSearch(requests).then((outcome) => {
562
+ if (cancelled || generation !== fetchGenRef.current) return;
563
+ const { results: nextResults, errors: nextErrors } = outcome;
564
+
565
+ // Discovery-driven refetch that came back with a failed source:
566
+ // the narrowing query erred, but the bootstrap results are still
567
+ // valid and on screen. Keep them — fall back to the prior result
568
+ // for any source this refetch failed to produce, and drop the
569
+ // spurious error rather than blanking the grid / raising a
570
+ // page-level error below. (Sources that DID return data still
571
+ // update to the narrowed set.)
572
+ if (isDiscoveryRefetch && requestedSources.some((s) => nextErrors[s.key] != null)) {
573
+ setResults((prev) => {
574
+ const merged = { ...prev };
575
+ for (const source of requestedSources) {
576
+ if (nextResults[source.key] != null) merged[source.key] = nextResults[source.key];
577
+ // else: refetch produced nothing for this source — retain prev.
578
+ }
579
+ return merged;
580
+ });
581
+ // Discovery is already complete for this channel, so there is no
582
+ // further channel id to resolve; just stop here without touching
583
+ // sourceErrors / the page-level error.
584
+ return;
585
+ }
586
+
587
+ setResults(nextResults);
588
+ setSourceErrors(nextErrors);
589
+
590
+ // Page-level error only when EVERY queried source failed; a mix of
591
+ // success + failure surfaces per-source (below) with the grid intact.
592
+ const allFailed =
593
+ requestedSources.length > 0 &&
594
+ requestedSources.every((source) => nextErrors[source.key] != null);
595
+ if (allFailed) {
596
+ // De-duplicate identical messages: when every source fails with the
597
+ // same backend error (e.g. "Search isn't enabled on this channel"),
598
+ // show it once rather than repeating it per source in the banner.
599
+ const combined = [
600
+ ...new Set(
601
+ requestedSources
602
+ .map((source) => nextErrors[source.key])
603
+ .filter((m): m is string => !!m),
604
+ ),
605
+ ].join("; ");
606
+ setError(combined || "Unified search failed");
607
+ }
608
+
609
+ // Content-type discovery: once a CMS source returns data, derive the
610
+ // discovery channel id from it and hand it to the session-cached hook.
611
+ for (const source of requestedSources) {
612
+ if (source.kind !== "cms") continue;
613
+ const channelId = resolveChannelId(nextResults[source.key]);
614
+ if (channelId) {
615
+ setDiscoveryChannelId((prev) => (prev === channelId ? prev : channelId));
616
+ // Persist the pointer keyed by THIS UIBundle so a reload seeds
617
+ // only this bundle's channel — never a different bundle's.
618
+ writeLastChannelId(uiBundleId, channelId);
619
+ break;
620
+ }
621
+ }
622
+ });
383
623
  })
384
624
  .catch((err: unknown) => {
385
- if (generation !== fetchGenRef.current) return;
625
+ if (cancelled || generation !== fetchGenRef.current) return;
386
626
  console.error(err);
627
+ // A discovery-driven refetch (content-type narrowing over an
628
+ // already-successful bootstrap) that fails must NOT surface as a
629
+ // page-level error or blank the good bootstrap results — the user
630
+ // still has valid, un-narrowed results on screen. Degrade silently
631
+ // and keep them; `finally` resets loading below.
632
+ if (isDiscoveryRefetch) return;
633
+ // A whole-request / network failure (or a failure to resolve the
634
+ // channel id) is page-level, preserving the original behaviour.
387
635
  setError(err instanceof Error ? err.message : "Unified search failed");
636
+ // Clear stale rows so a prior page's results don't linger under the
637
+ // error banner (the per-source layout would otherwise keep rendering
638
+ // them). `finally` resets loading below.
639
+ setResults({});
640
+ setSourceErrors({});
388
641
  })
389
642
  .finally(() => {
390
- if (generation !== fetchGenRef.current) return;
643
+ if (cancelled || generation !== fetchGenRef.current) return;
391
644
  setLoading(false);
392
645
  });
646
+
647
+ return () => {
648
+ cancelled = true;
649
+ };
393
650
  // requestKey is the dependency-of-record; sourceStates and q are
394
651
  // captured via the ref-like closure above each render.
395
652
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -469,7 +726,9 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
469
726
  config: source,
470
727
  result,
471
728
  loading,
472
- error,
729
+ // Per-source error (set only when THIS source failed) — not the
730
+ // page-level `error` (which is set only when every source failed).
731
+ error: sourceErrors[source.key] ?? null,
473
732
  filters: {
474
733
  active: state?.filters ?? [],
475
734
  set: setFilter,
@@ -492,7 +751,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
492
751
  };
493
752
  }
494
753
  return out;
495
- }, [config.sources, sourceStates, results, loading, error, updateSource, pagination]);
754
+ }, [config.sources, sourceStates, results, loading, sourceErrors, updateSource, pagination]);
496
755
 
497
756
  const scopeLocked = lockedScope !== undefined;
498
757
 
@@ -588,8 +847,10 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
588
847
  goToNextPage: () => setGlobalPageIndex((p) => p + 1),
589
848
  goToPreviousPage: () => setGlobalPageIndex((p) => Math.max(0, p - 1)),
590
849
  goToPage: (next: number) => setGlobalPageIndex(Math.max(0, Math.floor(next))),
850
+ // Validate the requested size against the configured options before it
851
+ // becomes `$cmsLimit` / `first`, matching the per-source setter.
591
852
  setPageSize: (size: number) => {
592
- setGlobalPageSize(size);
853
+ setGlobalPageSize(validatePageSize(pagination, size));
593
854
  setGlobalPageIndex(0);
594
855
  },
595
856
  };
@@ -683,6 +944,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
683
944
  globalPagination,
684
945
  loading,
685
946
  error,
947
+ discoveredContentTypes,
686
948
  resetAll,
687
949
  }),
688
950
  [
@@ -697,6 +959,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
697
959
  globalPagination,
698
960
  loading,
699
961
  error,
962
+ discoveredContentTypes,
700
963
  resetAll,
701
964
  ],
702
965
  );
@@ -31,6 +31,11 @@ export { PaginationControls } from "./components/controls/PaginationControls";
31
31
  export { ScopeSelector } from "./components/controls/ScopeSelector";
32
32
  export { ActiveFilters } from "./components/filters/ActiveFilters";
33
33
  export { DefaultResultRow } from "./components/results/DefaultResultRow";
34
+ export { GlobalSearchBox } from "./components/GlobalSearchBox";
35
+ export type { GlobalSearchBoxProps } from "./components/GlobalSearchBox";
36
+ export { CmsResultRow } from "./components/results/CmsResultRow";
37
+ export { resolveResultRenderer } from "./components/results/resolveResultRenderer";
38
+ export type { ResultRenderer } from "./components/results/resolveResultRenderer";
34
39
  export { DefaultFilterPanel } from "./components/filters/DefaultFilterPanel";
35
40
  export {
36
41
  FilterProvider,
@@ -72,11 +77,30 @@ export { fetchDistinctValues } from "./api/distinctValuesService";
72
77
  export type { PicklistOption } from "./api/distinctValuesService";
73
78
 
74
79
  export { config } from "./loadConfig";
80
+ export { MIN_QUERY_LENGTH } from "./constants";
75
81
 
76
- export { ALL_SCOPE } from "./types";
82
+ // CMS adapter surface — the registered adapter, its config type, and the
83
+ // runtime content-type discovery hook. Apps adding a CMS source import
84
+ // `CmsSourceConfig` for typing; advanced integrations can reuse the adapter
85
+ // contracts and the discovery hook directly.
86
+ export { cmsAdapter } from "./adapters/cms";
87
+ export { useSearchableContentTypes } from "./adapters/cms/hooks/useSearchableContentTypes";
88
+ export type { UseSearchableContentTypesResult } from "./adapters/cms/hooks/useSearchableContentTypes";
89
+ export { formatContentTypeLabel, isValidCmsFqn } from "./adapters/cms/contentTypeUtils";
90
+ export type {
91
+ SourceAdapter,
92
+ SourceRequest as AdapterSourceRequest,
93
+ QueryFragmentContribution,
94
+ } from "./adapters/types";
95
+ export type { CmsSourceConfig, CmsSearchItem, CmsSearchResponse } from "./adapters/cms/types";
96
+ export { getAdapter } from "./adapters/registry";
97
+ export type { RunSearchOutcome } from "./api/searchService";
98
+
99
+ export { ALL_SCOPE, CMS_SCOPE_SEPARATOR, parseScope } from "./types";
77
100
  export type {
78
101
  DisplayField,
79
102
  SObjectSourceConfig,
103
+ SourceConfig,
80
104
  SearchConfig,
81
105
  SearchPaginationConfig,
82
106
  SourceController,
@@ -84,6 +108,7 @@ export type {
84
108
  SourcePageInfo,
85
109
  SearchHandle,
86
110
  SearchScope,
111
+ ParsedScope,
87
112
  RenderResultFn,
88
113
  GlobalPagination,
89
114
  MergedResultItem,