@salesforce/ui-bundle-template-app-react-sample-b2e 11.55.1 → 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 (52) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/__tests__/queryBuilder.test.ts +166 -0
  5. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/channelResolver.test.ts +73 -0
  6. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +127 -0
  7. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeSessionCache.test.ts +114 -0
  8. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeUtils.test.ts +82 -0
  9. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/orgApiVersionService.test.ts +57 -0
  10. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/parseResponse.test.ts +102 -0
  11. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +98 -0
  12. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchableContentTypesService.test.ts +153 -0
  13. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  14. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  15. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  16. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  17. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  18. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  19. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  20. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  21. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +54 -0
  22. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  23. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  24. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/types.ts +66 -0
  25. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/registry.ts +38 -0
  26. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/index.ts +19 -0
  27. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  28. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  29. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +101 -0
  30. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/__tests__/searchService.test.ts +304 -0
  31. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +110 -45
  32. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  33. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  34. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +31 -13
  35. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +17 -11
  36. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +9 -4
  37. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/Search.test.tsx +173 -0
  38. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  39. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  40. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/CmsResultRow.test.tsx +139 -0
  41. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/resolveResultRenderer.test.ts +104 -0
  42. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  43. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +7 -2
  44. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/constants.ts +8 -0
  45. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +313 -50
  46. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +26 -1
  47. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +62 -118
  48. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +74 -5
  49. package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
  50. package/dist/package-lock.json +2 -2
  51. package/dist/package.json +1 -1
  52. package/package.json +2 -2
@@ -31,7 +31,6 @@
31
31
  * - **Global pager** — one {@link PaginationControls} over the cumulative set.
32
32
  */
33
33
 
34
- import type { ReactNode } from "react";
35
34
  import { AlertCircle } from "lucide-react";
36
35
  import { Alert, AlertDescription, AlertTitle } from "../../../components/ui/alert";
37
36
  import { Skeleton } from "../../../components/ui/skeleton";
@@ -40,11 +39,9 @@ import { DefaultFilterPanel } from "./filters/DefaultFilterPanel";
40
39
  import { FilterProvider, FilterResetButton } from "./filters/FilterContext";
41
40
  import { PaginationControls } from "./controls/PaginationControls";
42
41
  import { SortControl } from "./controls/SortControl";
43
- import { DefaultResultRow } from "./results/DefaultResultRow";
42
+ import { resolveResultRenderer, type ResultRenderer } from "./results/resolveResultRenderer";
44
43
  import { ALL_SCOPE, type SearchHandle, type SourceController } from "../types";
45
44
 
46
- type ResultRenderer = ((node: unknown) => ReactNode) | false;
47
-
48
45
  export interface MergedSearchResultsProps {
49
46
  handle: SearchHandle;
50
47
  /**
@@ -126,6 +123,8 @@ export function MergedSearchResults({
126
123
 
127
124
  {soleController && <SingleSourceChrome controller={soleController} />}
128
125
 
126
+ {/* Page-level error — set only when every in-scope source failed (or a
127
+ whole-request/network failure). Shown once, above the grid. */}
129
128
  {error && (
130
129
  <Alert variant="destructive" role="alert">
131
130
  <AlertCircle />
@@ -134,6 +133,20 @@ export function MergedSearchResults({
134
133
  </Alert>
135
134
  )}
136
135
 
136
+ {/* Per-source errors — a source that failed while a sibling succeeded
137
+ surfaces its own banner without blanking the shared grid. Suppressed
138
+ when the page-level error already covers the all-failed case. */}
139
+ {!error &&
140
+ handle.inScopeSources
141
+ .filter((c) => !!c.error)
142
+ .map((c) => (
143
+ <Alert key={c.config.key} variant="destructive" role="alert">
144
+ <AlertCircle />
145
+ <AlertTitle>Failed to load {c.config.label}</AlertTitle>
146
+ <AlertDescription>{c.error}</AlertDescription>
147
+ </Alert>
148
+ ))}
149
+
137
150
  {/* Results count — its own row, directly above the grid, in every scope.
138
151
  Hidden when there are none (the empty state covers that case). */}
139
152
  {!loading && !error && page.totalCount > 0 && (
@@ -155,14 +168,14 @@ export function MergedSearchResults({
155
168
  ) : (
156
169
  <ul className={gridClassName}>
157
170
  {mergedResults.map(({ sourceKey, node }, i) => {
158
- const override = renderResult?.[sourceKey];
159
- if (override === false) return null;
160
171
  const source = handle.sources[sourceKey]?.config;
161
- const rendered = override ? (
162
- override(node)
163
- ) : source ? (
164
- <DefaultResultRow node={node} source={source} />
165
- ) : null;
172
+ if (!source) return null;
173
+ // Resolve via the shared helper so CMS nodes render through
174
+ // CmsResultRow (never DefaultResultRow) in the merged grid too;
175
+ // a `renderResult[key]` override — including `false` — still wins.
176
+ const renderer = resolveResultRenderer(source, renderResult);
177
+ if (renderer === false) return null;
178
+ const rendered = renderer(node);
166
179
  const badge =
167
180
  showSourceBadges && source ? (
168
181
  <SourceBadge
@@ -237,15 +250,19 @@ interface SingleSourceChromeProps {
237
250
  */
238
251
  function SingleSourceChrome({ controller }: SingleSourceChromeProps) {
239
252
  const { config: source, filters, sort } = controller;
240
- const showFilterPanel = (source.filterBy?.length ?? 0) > 0;
241
- const showSortControl = (source.sortBy?.length ?? 0) > 0;
253
+ // Filter/sort chrome is SObject-only — narrow on `kind` so the union widening
254
+ // doesn't regress SObject behavior. CMS sources render no filter/sort chrome.
255
+ const sObjectSource = source.kind === "sobject" ? source : null;
256
+ const sortBy = sObjectSource?.sortBy;
257
+ const showFilterPanel = (sObjectSource?.filterBy?.length ?? 0) > 0;
258
+ const showSortControl = (sortBy?.length ?? 0) > 0;
242
259
  const showActiveChips = filters.active.length > 0;
243
260
 
244
261
  if (!showFilterPanel && !showSortControl && !showActiveChips) return null;
245
262
 
246
263
  return (
247
264
  <div className="flex flex-col gap-4">
248
- {showFilterPanel && (
265
+ {showFilterPanel && sObjectSource && (
249
266
  <FilterProvider
250
267
  filters={filters.active}
251
268
  onFilterChange={filters.set}
@@ -258,7 +275,7 @@ function SingleSourceChrome({ controller }: SingleSourceChromeProps) {
258
275
  <FilterResetButton size="sm" />
259
276
  </div>
260
277
  <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
261
- <DefaultFilterPanel source={source} />
278
+ <DefaultFilterPanel source={sObjectSource} />
262
279
  </div>
263
280
  </div>
264
281
  </FilterProvider>
@@ -266,8 +283,8 @@ function SingleSourceChrome({ controller }: SingleSourceChromeProps) {
266
283
 
267
284
  {(showSortControl || showActiveChips) && (
268
285
  <div className="flex flex-wrap items-center gap-2">
269
- {showSortControl && source.sortBy && (
270
- <SortControl configs={source.sortBy} sort={sort.current} onSortChange={sort.set} />
286
+ {showSortControl && sortBy && (
287
+ <SortControl configs={sortBy} sort={sort.current} onSortChange={sort.set} />
271
288
  )}
272
289
  {showActiveChips && <ActiveFilters filters={filters.active} onRemove={filters.remove} />}
273
290
  </div>
@@ -29,6 +29,8 @@
29
29
  */
30
30
 
31
31
  import { useMemo, type ReactNode } from "react";
32
+ import { AlertCircle } from "lucide-react";
33
+ import { Alert, AlertDescription, AlertTitle } from "../../../components/ui/alert";
32
34
  import { useSearch } from "../hooks/useSearch";
33
35
  import { SearchBar } from "./controls/SearchBar";
34
36
  import { ScopeSelector } from "./controls/ScopeSelector";
@@ -152,6 +154,7 @@ export function Search({
152
154
  scope={handle.scope}
153
155
  onScopeChange={handle.setScope}
154
156
  allLabel={allScopeLabel}
157
+ discoveredContentTypes={handle.discoveredContentTypes}
155
158
  />
156
159
  )}
157
160
  </div>
@@ -164,20 +167,35 @@ export function Search({
164
167
  />
165
168
  ) : (
166
169
  <>
167
- {/* Aggregate count on its own row, directly above the results —
168
- only in "All" scope, where the per-source sections don't each
169
- carry a single meaningful total. */}
170
- {showTotalResults && !handle.loading && (
171
- <p className="text-sm text-muted-foreground" role="status">
172
- {totalResults} total result{totalResults === 1 ? "" : "s"}
173
- </p>
170
+ {handle.error ? (
171
+ /* Page-level failure (every queried source failed, or a
172
+ whole-request/network error). Show the single consolidated
173
+ banner and nothing else — the per-source sections would each
174
+ repeat the same "Failed to load <source>" error and their
175
+ (now empty) filter/sort chrome, so suppress them entirely. */
176
+ <Alert variant="destructive" role="alert">
177
+ <AlertCircle aria-hidden="true" />
178
+ <AlertTitle>Search failed</AlertTitle>
179
+ <AlertDescription>{handle.error}</AlertDescription>
180
+ </Alert>
181
+ ) : (
182
+ <>
183
+ {/* Aggregate count on its own row, directly above the results —
184
+ only in "All" scope, where the per-source sections don't each
185
+ carry a single meaningful total. */}
186
+ {showTotalResults && !handle.loading && (
187
+ <p className="text-sm text-muted-foreground" role="status">
188
+ {totalResults} total result{totalResults === 1 ? "" : "s"}
189
+ </p>
190
+ )}
191
+ <SearchResults
192
+ handle={handle}
193
+ renderResult={renderResult}
194
+ renderFilters={renderFilters}
195
+ emptyMessages={emptyMessages}
196
+ />
197
+ </>
174
198
  )}
175
- <SearchResults
176
- handle={handle}
177
- renderResult={renderResult}
178
- renderFilters={renderFilters}
179
- emptyMessages={emptyMessages}
180
- />
181
199
  </>
182
200
  )}
183
201
  </div>
@@ -21,11 +21,10 @@
21
21
 
22
22
  import type { ReactNode } from "react";
23
23
  import { SourceSection } from "./SourceSection";
24
- import { DefaultResultRow } from "./results/DefaultResultRow";
25
24
  import { DefaultFilterPanel } from "./filters/DefaultFilterPanel";
26
- import { ALL_SCOPE, type SearchHandle } from "../types";
25
+ import { resolveResultRenderer, type ResultRenderer } from "./results/resolveResultRenderer";
26
+ import { ALL_SCOPE, parseScope, type SearchHandle } from "../types";
27
27
 
28
- type ResultRenderer = ((node: unknown) => ReactNode) | false;
29
28
  type FilterRenderer = (() => ReactNode) | false;
30
29
 
31
30
  interface SearchResultsProps {
@@ -42,23 +41,30 @@ export function SearchResults({
42
41
  emptyMessages,
43
42
  }: SearchResultsProps) {
44
43
  const filtersHidden = handle.scope === ALL_SCOPE;
44
+ // Match sections against the base scope so a "<cmsKey>:<fqn>" scope still
45
+ // renders the CMS source's section (the fqn only narrows the query).
46
+ const { base: scopeBase } = parseScope(handle.scope);
45
47
  return (
46
48
  <div className="space-y-10">
47
49
  {Object.entries(handle.sources).map(([key, controller]) => {
48
- if (handle.scope !== ALL_SCOPE && handle.scope !== key) return null;
49
- const overrideResult = renderResult?.[key];
50
- if (overrideResult === false) return null;
51
- const resolvedRenderResult: (node: unknown) => ReactNode =
52
- overrideResult ?? ((node) => <DefaultResultRow node={node} source={controller.config} />);
50
+ if (scopeBase !== ALL_SCOPE && scopeBase !== key) return null;
51
+ // Resolve the renderer through the shared helper so a CMS source
52
+ // renders via CmsResultRow (never DefaultResultRow) in this mode; a
53
+ // `renderResult[key]` override — including `false` (hide) — still wins.
54
+ const resolvedRenderResult = resolveResultRenderer(controller.config, renderResult);
55
+ if (resolvedRenderResult === false) return null;
53
56
 
57
+ // CMS sources have no `filterBy`; the guard below keeps their chrome off.
54
58
  const overrideFilters = renderFilters?.[key];
55
- const hasFilterFields = (controller.config.filterBy?.length ?? 0) > 0;
59
+ const sourceConfig = controller.config;
60
+ const hasFilterFields =
61
+ sourceConfig.kind === "sobject" && (sourceConfig.filterBy?.length ?? 0) > 0;
56
62
  const resolvedRenderFilters: (() => ReactNode) | undefined =
57
63
  overrideFilters === false
58
64
  ? undefined
59
65
  : (overrideFilters ??
60
- (hasFilterFields
61
- ? () => <DefaultFilterPanel source={controller.config} />
66
+ (hasFilterFields && sourceConfig.kind === "sobject"
67
+ ? () => <DefaultFilterPanel source={sourceConfig} />
62
68
  : undefined));
63
69
 
64
70
  return (
@@ -54,9 +54,14 @@ export function SourceSection({
54
54
 
55
55
  const pageSizeOptions = pagination.pageSizeOptions ?? [pagination.pageSize];
56
56
 
57
+ // `sortBy` / `idField` are SObject-only — narrow on `kind` so the union
58
+ // widening of `config` doesn't regress SObject behavior (CMS has neither).
59
+ const sortBy = config.kind === "sobject" ? config.sortBy : undefined;
60
+ const idField = config.kind === "sobject" ? config.idField : undefined;
61
+
57
62
  const showFilterPanel = !!renderFilters && !hideFilterChrome;
58
63
  const showActiveChips = !hideFilterChrome && filters.active.length > 0;
59
- const showSortControl = !hideFilterChrome && (config.sortBy?.length ?? 0) > 0;
64
+ const showSortControl = !hideFilterChrome && (sortBy?.length ?? 0) > 0;
60
65
 
61
66
  // Persistent, always-mounted live region: screen readers announce result
62
67
  // updates (searching → count → empty → error) without an Enter keypress.
@@ -102,8 +107,8 @@ export function SourceSection({
102
107
 
103
108
  {(showSortControl || showActiveChips) && (
104
109
  <div className="flex flex-wrap items-center gap-2 mb-3">
105
- {showSortControl && config.sortBy ? (
106
- <SortControl configs={config.sortBy} sort={sort.current} onSortChange={sort.set} />
110
+ {showSortControl && sortBy ? (
111
+ <SortControl configs={sortBy} sort={sort.current} onSortChange={sort.set} />
107
112
  ) : null}
108
113
  {showActiveChips && (
109
114
  <ActiveFilters filters={filters.active} onRemove={filters.remove} />
@@ -139,7 +144,7 @@ export function SourceSection({
139
144
  {showResults && (
140
145
  <ul className="divide-y">
141
146
  {nodes.map((node, i) => (
142
- <li key={getNodeKey(node, config.idField, i)} className="py-3">
147
+ <li key={getNodeKey(node, idField, i)} className="py-3">
143
148
  {renderResult(node)}
144
149
  </li>
145
150
  ))}
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ /**
8
+ * Tests the error-display contract of the drop-in `<Search>` (per-source
9
+ * layout): a page-level failure (every queried source failed) must suppress
10
+ * the per-source sections, so the same error isn't repeated as a
11
+ * "Failed to load <source>" card under each section's heading.
12
+ *
13
+ * The banner UI itself (`__inherit__alert`) is a build-time inheritance stub
14
+ * that renders nothing in this feature's own test harness, so we assert the
15
+ * observable behavior instead: whether the per-source section headings
16
+ * (`<h2>Accounts</h2>`, `<h2>Contacts</h2>`) render.
17
+ */
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+ import { render, screen } from "@testing-library/react";
20
+ import { MemoryRouter } from "react-router";
21
+ import { Search } from "../Search";
22
+ import type { SearchConfig, SearchHandle, SourceController } from "../../types";
23
+ import { ALL_SCOPE } from "../../types";
24
+
25
+ // The component tree transitively imports the platform SDK (via the filter
26
+ // panel's data hook). We drive `<Search>` entirely off a mocked handle and
27
+ // never fetch, so stub the SDK module to keep vite from resolving its
28
+ // internals under jsdom.
29
+ vi.mock("@salesforce/platform-sdk", () => ({
30
+ createDataSDK: vi.fn(),
31
+ getCurrentApp: vi.fn(() => ({ identity: { bundleId: "" } })),
32
+ }));
33
+
34
+ // Drive the component purely off a mocked handle so we assert rendering, not
35
+ // fetching. `useSearch` has its own integration seams elsewhere.
36
+ const useSearchMock = vi.fn<() => SearchHandle>();
37
+ vi.mock("../../hooks/useSearch", () => ({
38
+ useSearch: () => useSearchMock(),
39
+ }));
40
+
41
+ const config: SearchConfig = {
42
+ sources: [
43
+ {
44
+ kind: "sobject",
45
+ objectName: "Account",
46
+ key: "accounts",
47
+ label: "Accounts",
48
+ searchableFields: ["Name"],
49
+ displayFields: ["Name"],
50
+ },
51
+ {
52
+ kind: "sobject",
53
+ objectName: "Contact",
54
+ key: "contacts",
55
+ label: "Contacts",
56
+ searchableFields: ["Name"],
57
+ displayFields: ["Name"],
58
+ },
59
+ ],
60
+ };
61
+
62
+ const noop = () => {};
63
+
64
+ function makeController(
65
+ config: SourceController["config"],
66
+ error: string | null,
67
+ ): SourceController {
68
+ return {
69
+ config,
70
+ result: null,
71
+ loading: false,
72
+ error,
73
+ filters: { active: [], set: noop, remove: noop },
74
+ sort: { current: null, set: noop },
75
+ pagination: {
76
+ pageSize: 10,
77
+ pageSizeOptions: [10],
78
+ pageIndex: 0,
79
+ hasNextPage: false,
80
+ hasPreviousPage: false,
81
+ setPageSize: noop,
82
+ goToNextPage: noop,
83
+ goToPreviousPage: noop,
84
+ },
85
+ };
86
+ }
87
+
88
+ function makeHandle(overrides: Partial<SearchHandle>): SearchHandle {
89
+ const sources: Record<string, SourceController> = {
90
+ accounts: makeController(config.sources[0], null),
91
+ contacts: makeController(config.sources[1], null),
92
+ };
93
+ return {
94
+ q: "test",
95
+ setQ: noop,
96
+ scope: ALL_SCOPE,
97
+ setScope: noop,
98
+ scopeLocked: false,
99
+ sources,
100
+ inScopeSources: Object.values(sources),
101
+ mergedResults: [],
102
+ globalPagination: {
103
+ pageIndex: 0,
104
+ pageCount: 0,
105
+ pageSize: 10,
106
+ pageSizeOptions: [10],
107
+ hasNextPage: false,
108
+ hasPreviousPage: false,
109
+ totalCount: 0,
110
+ goToNextPage: noop,
111
+ goToPreviousPage: noop,
112
+ goToPage: noop,
113
+ setPageSize: noop,
114
+ },
115
+ loading: false,
116
+ error: null,
117
+ discoveredContentTypes: [],
118
+ resetAll: noop,
119
+ ...overrides,
120
+ };
121
+ }
122
+
123
+ function renderSearch() {
124
+ return render(
125
+ <MemoryRouter>
126
+ <Search config={config} />
127
+ </MemoryRouter>,
128
+ );
129
+ }
130
+
131
+ describe("<Search> error display (per-source layout)", () => {
132
+ beforeEach(() => {
133
+ useSearchMock.mockReset();
134
+ });
135
+
136
+ it("suppresses the per-source sections when every source failed (page-level error set)", () => {
137
+ useSearchMock.mockReturnValue(
138
+ makeHandle({
139
+ error: "Search isn't enabled on this channel or site.",
140
+ sources: {
141
+ accounts: makeController(config.sources[0], "Search failed for this source."),
142
+ contacts: makeController(config.sources[1], "Search failed for this source."),
143
+ },
144
+ }),
145
+ );
146
+
147
+ renderSearch();
148
+
149
+ // No per-source section headings — the whole `<SearchResults>` tree is
150
+ // suppressed, so the same error can't repeat under each section.
151
+ expect(screen.queryByRole("heading", { name: "Accounts" })).not.toBeInTheDocument();
152
+ expect(screen.queryByRole("heading", { name: "Contacts" })).not.toBeInTheDocument();
153
+ });
154
+
155
+ it("renders the per-source sections when only one source failed (no page-level error)", () => {
156
+ useSearchMock.mockReturnValue(
157
+ makeHandle({
158
+ error: null,
159
+ sources: {
160
+ accounts: makeController(config.sources[0], "Search failed for this source."),
161
+ contacts: makeController(config.sources[1], null),
162
+ },
163
+ }),
164
+ );
165
+
166
+ renderSearch();
167
+
168
+ // The failed source surfaces its own card inline while the surviving
169
+ // source's section stays on screen — so both section headings render.
170
+ expect(screen.getByRole("heading", { name: "Accounts" })).toBeInTheDocument();
171
+ expect(screen.getByRole("heading", { name: "Contacts" })).toBeInTheDocument();
172
+ });
173
+ });
@@ -3,10 +3,14 @@
3
3
  *
4
4
  * Sits next to the search bar in the default {@link Search} layout. The
5
5
  * options are derived from the configured sources: an "All" entry plus one
6
- * entry per source (using each source's `label`).
6
+ * entry per source (using each source's `label`). When CMS content types have
7
+ * been discovered at runtime, one extra entry per type is appended under its
8
+ * CMS source — value `"<cmsKey>:<fqn>"`, label the server-provided content-type
9
+ * label — so a user can narrow a CMS search to a single content type.
7
10
  *
8
11
  * Selecting a non-"All" scope tells {@link useSearch} to fetch and render
9
- * only that source. Switching scope resets per-source pagination cursors —
12
+ * only that source. Selecting a `"<cmsKey>:<fqn>"` entry runs a CMS-only search
13
+ * for that content type. Switching scope resets per-source pagination cursors —
10
14
  * the visible result set is changing.
11
15
  */
12
16
 
@@ -17,13 +21,22 @@ import {
17
21
  SelectTrigger,
18
22
  SelectValue,
19
23
  } from "../../../../components/ui/select";
20
- import { ALL_SCOPE, type SearchConfig, type SearchScope } from "../../types";
24
+ import { ALL_SCOPE, CMS_SCOPE_SEPARATOR, type SearchConfig, type SearchScope } from "../../types";
25
+ import type { DiscoveredContentType } from "../../adapters/cms/contentTypeUtils";
21
26
 
22
27
  interface ScopeSelectorProps {
23
28
  config: SearchConfig;
24
29
  scope: SearchScope;
25
30
  onScopeChange: (scope: SearchScope) => void;
26
31
  allLabel?: string;
32
+ /**
33
+ * CMS content types discovered at runtime (from
34
+ * {@link SearchHandle.discoveredContentTypes}) — each with its FQN and
35
+ * server-provided display `label`. Appended as per-type entries under the CMS
36
+ * source once discovery completes; omit or pass `[]` before then. No-op when
37
+ * the config has no CMS source.
38
+ */
39
+ discoveredContentTypes?: DiscoveredContentType[];
27
40
  className?: string;
28
41
  }
29
42
 
@@ -32,9 +45,21 @@ export function ScopeSelector({
32
45
  scope,
33
46
  onScopeChange,
34
47
  allLabel = "All",
48
+ discoveredContentTypes = [],
35
49
  className,
36
50
  }: ScopeSelectorProps) {
37
51
  if (config.sources.length === 0) return null;
52
+ // Discovered content types attach to the (single) CMS source. Entries are
53
+ // injected only after discovery, so the dropdown starts with just the config
54
+ // sources and grows once the CMS channel's types are known.
55
+ const cmsSource = config.sources.find((s) => s.kind === "cms");
56
+ const hasDiscoveredTypes = !!cmsSource && discoveredContentTypes.length > 0;
57
+ const contentTypeEntries = hasDiscoveredTypes
58
+ ? discoveredContentTypes.map((ct) => ({
59
+ value: `${cmsSource!.key}${CMS_SCOPE_SEPARATOR}${ct.fqn}`,
60
+ label: ct.label,
61
+ }))
62
+ : [];
38
63
  return (
39
64
  <div className={className}>
40
65
  <Select value={scope} onValueChange={onScopeChange}>
@@ -43,9 +68,28 @@ export function ScopeSelector({
43
68
  </SelectTrigger>
44
69
  <SelectContent>
45
70
  <SelectItem value={ALL_SCOPE}>{allLabel}</SelectItem>
46
- {config.sources.map((source) => (
47
- <SelectItem key={source.key} value={source.key}>
48
- {source.label}
71
+ {config.sources.map((source) => {
72
+ // Once the CMS channel's content types are known, the generic CMS
73
+ // source entry (e.g. "Content") is redundant — its per-type entries
74
+ // (appended below) cover the same source at finer granularity, so
75
+ // drop the catch-all to avoid a duplicate-looking scope option.
76
+ // EXCEPTION: keep it while it is the current scope. Discovery can
77
+ // complete AFTER the user has selected the generic CMS scope; if we
78
+ // removed the only matching item, the controlled Radix select would
79
+ // hold a value no item represents and render blank — leaving AT
80
+ // users unable to identify the active scope (WCAG 4.1.2).
81
+ if (source.kind === "cms" && hasDiscoveredTypes && scope !== source.key) {
82
+ return null;
83
+ }
84
+ return (
85
+ <SelectItem key={source.key} value={source.key}>
86
+ {source.label}
87
+ </SelectItem>
88
+ );
89
+ })}
90
+ {contentTypeEntries.map((entry) => (
91
+ <SelectItem key={entry.value} value={entry.value}>
92
+ {entry.label}
49
93
  </SelectItem>
50
94
  ))}
51
95
  </SelectContent>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Per-node row for a CMS source. Used by both the per-source section
3
+ * (`SearchResults`) and the merged grid (`MergedSearchResults`) via the shared
4
+ * `resolveResultRenderer` (UI-owned) whenever `source.kind === "cms"`.
5
+ *
6
+ * Renders a title, a published-date subtitle, an optional content-type badge,
7
+ * and a react-router `<Link>` when a route can be built.
8
+ *
9
+ * Link target:
10
+ * - `source.routePattern` tokens: `:id` (managedContentId), `:key`
11
+ * (managedContentKey), `:contentType` (contentType).
12
+ * - falls back to `/content/:contentType/:id` when no `routePattern` is
13
+ * configured.
14
+ *
15
+ * The content-type label is rendered only when its FQN passes `isValidCmsFqn`.
16
+ */
17
+
18
+ import type { ReactNode } from "react";
19
+ import { Link } from "react-router";
20
+ import type { CmsSourceConfig, CmsSearchItem } from "../../adapters/cms/types";
21
+ import { formatContentTypeLabel, isValidCmsFqn } from "../../adapters/cms/contentTypeUtils";
22
+
23
+ /** Formats an ISO date string for display; echoes the raw string on failure. */
24
+ function formatPublishedDate(dateStr: string): string {
25
+ const date = new Date(dateStr);
26
+ if (isNaN(date.getTime())) return dateStr;
27
+ return date.toLocaleDateString(undefined, {
28
+ year: "numeric",
29
+ month: "short",
30
+ day: "numeric",
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Substitutes `:id` / `:key` / `:contentType` tokens in a route pattern with
36
+ * the item's values (URL-encoded). Returns `null` if a required token has no
37
+ * value.
38
+ */
39
+ function resolveRoute(pattern: string, item: CmsSearchItem): string | null {
40
+ const tokens: Record<string, string | undefined> = {
41
+ id: item.managedContentId,
42
+ key: item.managedContentKey,
43
+ contentType: item.contentType,
44
+ };
45
+ let failed = false;
46
+ const out = pattern.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, token: string) => {
47
+ const value = tokens[token];
48
+ if (value == null || value === "") {
49
+ failed = true;
50
+ return "";
51
+ }
52
+ return encodeURIComponent(value);
53
+ });
54
+ return failed ? null : out;
55
+ }
56
+
57
+ interface CmsResultRowProps {
58
+ node: unknown;
59
+ source: CmsSourceConfig;
60
+ }
61
+
62
+ export function CmsResultRow({ node, source }: CmsResultRowProps) {
63
+ const item = node as CmsSearchItem;
64
+
65
+ const publishedDate = item.managedContentChannelDeliveryDetails?.[0]?.publishedDate;
66
+ const showType = typeof item.contentType === "string" && isValidCmsFqn(item.contentType);
67
+
68
+ const subtitleParts: string[] = [];
69
+ if (showType) subtitleParts.push(formatContentTypeLabel(item.contentType));
70
+ if (publishedDate) subtitleParts.push(`Published: ${formatPublishedDate(publishedDate)}`);
71
+
72
+ const content: ReactNode = (
73
+ <>
74
+ <span className="font-medium">{item.title ?? "—"}</span>
75
+ {item.highlightedSnippet && (
76
+ <p className="text-sm text-muted-foreground">{item.highlightedSnippet}</p>
77
+ )}
78
+ {subtitleParts.length > 0 && (
79
+ <p className="text-sm text-muted-foreground">{subtitleParts.join(" · ")}</p>
80
+ )}
81
+ </>
82
+ );
83
+
84
+ // Only render a drill-down link when the source explicitly configures a
85
+ // routePattern. Without one, content rows render as plain (non-linked) rows
86
+ // so they never navigate to an unbuilt detail route.
87
+ const href = source.routePattern ? resolveRoute(source.routePattern, item) : null;
88
+ if (href) {
89
+ return (
90
+ <Link
91
+ to={href}
92
+ className="block hover:bg-accent rounded-md px-3 -mx-3 py-1 transition-colors"
93
+ aria-label={`View content: ${item.title}`}
94
+ >
95
+ {content}
96
+ </Link>
97
+ );
98
+ }
99
+
100
+ return <div className="px-3 -mx-3 py-1">{content}</div>;
101
+ }