@salesforce/ui-bundle-template-app-react-template-b2e 11.55.1 → 11.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/reactinternalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  5. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  6. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  7. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  8. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  9. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  10. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  11. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  12. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/index.ts +54 -0
  13. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  14. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  15. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/types.ts +66 -0
  16. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/registry.ts +38 -0
  17. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/index.ts +19 -0
  18. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  19. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  20. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts +101 -0
  21. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/api/searchService.ts +110 -45
  22. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  23. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  24. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/Search.tsx +31 -13
  25. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SearchResults.tsx +17 -11
  26. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SourceSection.tsx +9 -4
  27. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  28. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  29. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  30. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json +7 -2
  31. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts +8 -0
  32. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/hooks/useSearch.ts +313 -50
  33. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/index.ts +26 -1
  34. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/queryBuilder.ts +62 -118
  35. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/types.ts +74 -5
  36. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/Home.tsx +3 -42
  37. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/routes.tsx +12 -5
  38. package/dist/force-app/main/default/uiBundles/reactinternalapp/tsconfig.tsbuildinfo +1 -1
  39. package/dist/package-lock.json +2 -2
  40. package/dist/package.json +1 -1
  41. package/package.json +4 -2
  42. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/AccountSearch.tsx +0 -25
@@ -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
  ))}
@@ -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
+ }
@@ -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
+ }
@@ -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
  }
@@ -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;