@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.
- package/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/README.md +235 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/__tests__/queryBuilder.test.ts +166 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/channelResolver.test.ts +73 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +127 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeSessionCache.test.ts +114 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeUtils.test.ts +82 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/orgApiVersionService.test.ts +57 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/parseResponse.test.ts +102 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +98 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchableContentTypesService.test.ts +153 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +54 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/types.ts +66 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/registry.ts +38 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/index.ts +19 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +101 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/__tests__/searchService.test.ts +304 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +110 -45
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +34 -17
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +31 -13
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +17 -11
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +9 -4
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/Search.test.tsx +173 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/CmsResultRow.test.tsx +139 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/resolveResultRenderer.test.ts +104 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +7 -2
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/constants.ts +8 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +313 -50
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +26 -1
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +62 -118
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +74 -5
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- 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 {
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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={
|
|
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 &&
|
|
270
|
-
<SortControl configs={
|
|
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
|
-
{
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 {
|
|
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 (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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={
|
|
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 && (
|
|
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 &&
|
|
106
|
-
<SortControl configs={
|
|
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,
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
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
|
+
}
|