@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.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/README.md +235 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/index.ts +54 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/types.ts +66 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/registry.ts +38 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/index.ts +19 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts +101 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/api/searchService.ts +110 -45
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/MergedSearchResults.tsx +34 -17
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/Search.tsx +31 -13
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SearchResults.tsx +17 -11
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SourceSection.tsx +9 -4
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json +7 -2
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts +8 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/hooks/useSearch.ts +313 -50
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/index.ts +26 -1
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/queryBuilder.ts +62 -118
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/types.ts +74 -5
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/Home.tsx +3 -42
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/routes.tsx +12 -5
- package/dist/force-app/main/default/uiBundles/reactinternalapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +4 -2
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/AccountSearch.tsx +0 -25
|
@@ -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
|
))}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for choosing how one source's result node is rendered.
|
|
3
|
+
*
|
|
4
|
+
* Every result layout (`SearchResults`, `MergedSearchResults`, and any future
|
|
5
|
+
* grid/list) MUST resolve its per-node renderer through this helper so the
|
|
6
|
+
* kind-aware default can never be missed in one mode. The rule, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. A `renderResult[source.key]` config override wins in EVERY mode —
|
|
9
|
+
* including `false`, which hides the source entirely.
|
|
10
|
+
* 2. Otherwise `source.kind === "cms"` → {@link CmsResultRow}.
|
|
11
|
+
* 3. Otherwise (SObject) → {@link DefaultResultRow}.
|
|
12
|
+
*
|
|
13
|
+
* Returning the same union the callers already use (`renderer | false`) lets a
|
|
14
|
+
* component early-return `null` for a hidden source without special-casing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createElement, type ReactNode } from "react";
|
|
18
|
+
import type { SourceConfig } from "../../types";
|
|
19
|
+
import { DefaultResultRow } from "./DefaultResultRow";
|
|
20
|
+
import { CmsResultRow } from "./CmsResultRow";
|
|
21
|
+
|
|
22
|
+
/** A per-node renderer, or `false` to hide the source's results. */
|
|
23
|
+
export type ResultRenderer = ((node: unknown) => ReactNode) | false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the renderer for one source. See the module doc for the precedence
|
|
27
|
+
* rules. `renderResult` is the optional per-source override map from `<Search>`.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveResultRenderer(
|
|
30
|
+
source: SourceConfig,
|
|
31
|
+
renderResult?: Record<string, ResultRenderer>,
|
|
32
|
+
): ResultRenderer {
|
|
33
|
+
const override = renderResult?.[source.key];
|
|
34
|
+
// A config override wins in every mode — including `false` (hide).
|
|
35
|
+
if (override !== undefined) return override;
|
|
36
|
+
|
|
37
|
+
// Assign a `displayName` so React DevTools shows a readable label for the
|
|
38
|
+
// per-node component factory and the `react/display-name` lint rule is
|
|
39
|
+
// satisfied. (This is a debug label only — it does not affect reference
|
|
40
|
+
// identity; a new function is still created on each call.)
|
|
41
|
+
if (source.kind === "cms") {
|
|
42
|
+
const renderCmsRow = (node: unknown) => createElement(CmsResultRow, { node, source });
|
|
43
|
+
renderCmsRow.displayName = "CmsResultRowRenderer";
|
|
44
|
+
return renderCmsRow;
|
|
45
|
+
}
|
|
46
|
+
const renderDefaultRow = (node: unknown) => createElement(DefaultResultRow, { node, source });
|
|
47
|
+
renderDefaultRow.displayName = "DefaultResultRowRenderer";
|
|
48
|
+
return renderDefaultRow;
|
|
49
|
+
}
|
package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pagination": {
|
|
3
|
+
"mode": "per-source",
|
|
3
4
|
"pageSize": 10,
|
|
4
5
|
"pageSizeOptions": [5, 10, 20]
|
|
5
6
|
},
|
|
@@ -38,7 +39,6 @@
|
|
|
38
39
|
"objectName": "Contact",
|
|
39
40
|
"label": "Contacts",
|
|
40
41
|
"labelSingular": "Contact",
|
|
41
|
-
"routePattern": "/contacts/:id",
|
|
42
42
|
"searchableFields": ["Name", "Email", "Phone"],
|
|
43
43
|
"displayFields": [
|
|
44
44
|
"Name",
|
|
@@ -58,7 +58,6 @@
|
|
|
58
58
|
"objectName": "Opportunity",
|
|
59
59
|
"label": "Opportunities",
|
|
60
60
|
"labelSingular": "Opportunity",
|
|
61
|
-
"routePattern": "/opportunities/:id",
|
|
62
61
|
"searchableFields": ["Name"],
|
|
63
62
|
"displayFields": [
|
|
64
63
|
"Name",
|
|
@@ -78,6 +77,12 @@
|
|
|
78
77
|
{ "field": "Name", "label": "Name" }
|
|
79
78
|
],
|
|
80
79
|
"defaultSort": { "field": "CloseDate", "direction": "DESC" }
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"kind": "cms",
|
|
83
|
+
"key": "content",
|
|
84
|
+
"label": "Content",
|
|
85
|
+
"labelSingular": "Content"
|
|
81
86
|
}
|
|
82
87
|
]
|
|
83
88
|
}
|
package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimum trimmed keyword length before a search fires. Both the SObject and
|
|
3
|
+
* CMS search backends reject terms shorter than this (returning errors that the
|
|
4
|
+
* UI would otherwise render as "Search failed for this source." on every
|
|
5
|
+
* source), so the query is gated client-side until the user types at least this
|
|
6
|
+
* many characters. Shared so every `<Search>` consumer inherits the same floor.
|
|
7
|
+
*/
|
|
8
|
+
export const MIN_QUERY_LENGTH = 3;
|