@salesforce/ui-bundle-template-app-react-template-b2e 11.55.0 → 11.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/reactinternalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  5. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  6. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  7. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  8. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  9. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  10. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  11. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  12. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/index.ts +54 -0
  13. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  14. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  15. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/types.ts +66 -0
  16. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/registry.ts +38 -0
  17. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/index.ts +19 -0
  18. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  19. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  20. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts +101 -0
  21. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/api/searchService.ts +110 -45
  22. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  23. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  24. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/Search.tsx +31 -13
  25. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SearchResults.tsx +17 -11
  26. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SourceSection.tsx +9 -4
  27. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  28. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  29. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  30. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json +7 -2
  31. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts +8 -0
  32. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/hooks/useSearch.ts +313 -50
  33. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/index.ts +26 -1
  34. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/queryBuilder.ts +62 -118
  35. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/types.ts +74 -5
  36. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/Home.tsx +3 -42
  37. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/routes.tsx +12 -5
  38. package/dist/force-app/main/default/uiBundles/reactinternalapp/tsconfig.tsbuildinfo +1 -1
  39. package/dist/package-lock.json +2 -2
  40. package/dist/package.json +1 -1
  41. package/package.json +4 -2
  42. package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/AccountSearch.tsx +0 -25
@@ -0,0 +1,141 @@
1
+ /**
2
+ * SObject fragment builder.
3
+ *
4
+ * Extracted verbatim from `queryBuilder.ts`: `buildSelectionBody`,
5
+ * `renderField`, `buildSourceWhere`, `assertValidKey`, `KEY_PATTERN`, and the
6
+ * per-source variable/selection assembly. Emits one `QueryFragmentContribution`
7
+ * with `placement: "uiapi.query"` — the `${key}: ${objectName}(...) { … }`
8
+ * selection, its 4 per-source variable declarations, and their 4 values.
9
+ *
10
+ * Behaviour is byte-for-byte equivalent to the original inline logic; this is a
11
+ * move, not a rewrite.
12
+ *
13
+ * Selection sets are generated from `displayFields`:
14
+ * - string `"Name"` → `Name @optional { value displayValue }`
15
+ * - `{ name, raw: true }` → `Name`
16
+ * - `{ name, subfields }` → nested `Name @optional { ... }`
17
+ *
18
+ * The `idField` (default "Id") is always emitted; it is **not** wrapped
19
+ * because uiapi's Id is a scalar.
20
+ */
21
+
22
+ import {
23
+ buildFilter,
24
+ buildGlobalQueryClause,
25
+ type ActiveFilterValue,
26
+ } from "../../utils/filterUtils";
27
+ import { buildOrderBy } from "../../utils/sortUtils";
28
+ import type { DisplayField, SObjectSourceConfig } from "../../types";
29
+ import type { QueryFragmentContribution, SourceRequest } from "../types";
30
+
31
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ function assertValidKey(key: string): void {
34
+ if (!KEY_PATTERN.test(key)) {
35
+ throw new Error(
36
+ `Invalid source key "${key}". Must match /^[A-Za-z_][A-Za-z0-9_]*$/ to be a valid GraphQL alias and variable prefix.`,
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Combines a per-source structured-filter clause with the global-q `or` clause.
43
+ * Returns `undefined` when neither side has any constraints.
44
+ */
45
+ function buildSourceWhere(
46
+ source: SObjectSourceConfig,
47
+ q: string,
48
+ filters: ActiveFilterValue[],
49
+ ): unknown {
50
+ const clauses: unknown[] = [];
51
+ const globalClause = buildGlobalQueryClause(q, source.searchableFields);
52
+ if (globalClause) clauses.push(globalClause);
53
+ const structured = buildFilter(filters, source.filterBy ?? []);
54
+ if (structured) clauses.push(structured);
55
+ if (clauses.length === 0) return undefined;
56
+ if (clauses.length === 1) return clauses[0];
57
+ return { and: clauses };
58
+ }
59
+
60
+ function buildSelectionBody(source: SObjectSourceConfig): string {
61
+ const idField = source.idField ?? "Id";
62
+ const fieldLines = [` ${idField}`];
63
+ for (const field of source.displayFields) {
64
+ fieldLines.push(...renderField(field, " "));
65
+ }
66
+ return [
67
+ ` edges { node {`,
68
+ ...fieldLines,
69
+ ` } }`,
70
+ ` pageInfo { hasNextPage hasPreviousPage startCursor endCursor }`,
71
+ ` totalCount`,
72
+ ].join("\n");
73
+ }
74
+
75
+ function renderField(field: DisplayField, indentStr: string): string[] {
76
+ if (typeof field === "string") {
77
+ return [`${indentStr}${field} @optional { value displayValue }`];
78
+ }
79
+ if ("raw" in field) {
80
+ return [`${indentStr}${field.name}`];
81
+ }
82
+ const lines = [`${indentStr}${field.name} @optional {`];
83
+ for (const sub of field.subfields) {
84
+ lines.push(...renderField(sub, indentStr + " "));
85
+ }
86
+ lines.push(`${indentStr}}`);
87
+ return lines;
88
+ }
89
+
90
+ /**
91
+ * Builds one SObject source's `QueryFragmentContribution`.
92
+ *
93
+ * The fragment is the `${key}: ${objectName}(...) { … }` selection that lives
94
+ * inside `uiapi.query`; the 4 declarations/values are the per-source
95
+ * `first / after / where / orderBy` variables. Output matches the original
96
+ * inline builder exactly.
97
+ */
98
+ export function buildSObjectFragment(
99
+ request: SourceRequest<SObjectSourceConfig>,
100
+ ): QueryFragmentContribution {
101
+ const { source } = request;
102
+ assertValidKey(source.key);
103
+
104
+ const firstVar = `${source.key}_first`;
105
+ const afterVar = `${source.key}_after`;
106
+ const whereVar = `${source.key}_where`;
107
+ const orderByVar = `${source.key}_orderBy`;
108
+ const whereType = source.whereTypeName ?? `${source.objectName}_Filter`;
109
+ const orderByType = source.orderByTypeName ?? `${source.objectName}_OrderBy`;
110
+
111
+ const variableDeclarations = [
112
+ `$${firstVar}: Int`,
113
+ `$${afterVar}: String`,
114
+ `$${whereVar}: ${whereType}`,
115
+ `$${orderByVar}: ${orderByType}`,
116
+ ];
117
+
118
+ const fragment =
119
+ `${source.key}: ${source.objectName}(` +
120
+ `first: $${firstVar}, ` +
121
+ `after: $${afterVar}, ` +
122
+ `where: $${whereVar}, ` +
123
+ `orderBy: $${orderByVar}` +
124
+ `) {\n` +
125
+ buildSelectionBody(source) +
126
+ `\n}`;
127
+
128
+ const variables: Record<string, unknown> = {
129
+ [firstVar]: request.pageSize,
130
+ [afterVar]: request.afterCursor ?? null,
131
+ [whereVar]: buildSourceWhere(source, request.q, request.filters) ?? null,
132
+ [orderByVar]: buildOrderBy(request.sort) ?? null,
133
+ };
134
+
135
+ return {
136
+ variableDeclarations,
137
+ variables,
138
+ placement: "uiapi.query",
139
+ fragment,
140
+ };
141
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Kind-agnostic adapter contracts for unified (multi-source) search.
3
+ *
4
+ * Every searchable backend ("sobject", "cms", …) is fronted by a
5
+ * {@link SourceAdapter}. The core query builder and result parser are
6
+ * completely kind-agnostic: they dispatch to the adapter registered for a
7
+ * source's `kind`, so a new backend plugs in by adding a `kind` + an adapter,
8
+ * never by touching the common builder/parser or any consumer.
9
+ *
10
+ * - {@link SourceAdapter.buildRequest} contributes a {@link QueryFragmentContribution}
11
+ * (its GraphQL fragment, variable declarations, and variable values) that the
12
+ * builder assembles into one combined document.
13
+ * - {@link SourceAdapter.parseResponse} receives the full response `data` root
14
+ * and extracts this source's `SourceResult`, returning `null` (never
15
+ * throwing) when its own subtree is absent or errored — this is what lets
16
+ * sources fail independently (partial-failure resilience).
17
+ */
18
+
19
+ import type { SourceConfig, SourceResult } from "../types";
20
+ import type { ActiveFilterValue } from "../utils/filterUtils";
21
+ import type { SortState } from "../utils/sortUtils";
22
+
23
+ /**
24
+ * One source's contribution to the combined GraphQL document.
25
+ *
26
+ * The builder concatenates every source's `variableDeclarations` into the
27
+ * `query Search(...)` header and merges every source's `variables` into one
28
+ * flat map. Fragments are placed by `placement`:
29
+ * - `"uiapi.query"` → inside the shared `uiapi { query { … } }` block.
30
+ * - `"root"` → a sibling of `uiapi` at the document root (e.g. the CMS
31
+ * `managed_content { … }` block).
32
+ *
33
+ * A contribution MAY declare zero extra variables (e.g. the CMS bootstrap call
34
+ * omits `$cmsContentTypeFQNs` entirely). The builder handles declaration lists of
35
+ * differing lengths, so an unused/undeclared variable is never emitted.
36
+ */
37
+ export interface QueryFragmentContribution {
38
+ /** Namespaced GraphQL variable declarations, e.g. `"$cmsKeyword: String!"`. */
39
+ variableDeclarations: string[];
40
+ /** Values for the declared variables, keyed by (unprefixed) variable name. */
41
+ variables: Record<string, unknown>;
42
+ /** Where the `fragment` is spliced into the combined document. */
43
+ placement: "uiapi.query" | "root";
44
+ /** The GraphQL selection fragment (unindented; the builder indents it). */
45
+ fragment: string;
46
+ }
47
+
48
+ /**
49
+ * A per-source search request. Generic over the source config so an adapter can
50
+ * narrow to its own config kind (e.g. `SourceRequest<CmsSourceConfig>`).
51
+ *
52
+ * The base fields (`source`/`q`/`filters`/`sort`/`pageSize`/`afterCursor`) are
53
+ * kind-agnostic. The trailing fields are optional and only populated for the
54
+ * kinds that need them; the sync query builder never resolves async values, so
55
+ * anything async (e.g. the CMS UIBundle id) is resolved by the caller and
56
+ * threaded in here.
57
+ */
58
+ export interface SourceRequest<T extends SourceConfig = SourceConfig> {
59
+ /** The config for the source this request targets. */
60
+ source: T;
61
+ /** The global search term, broadcast to every source. */
62
+ q: string;
63
+ /** Active structured filters (SObject-relevant; other kinds may ignore). */
64
+ filters: ActiveFilterValue[];
65
+ /** Active sort (SObject-relevant; other kinds may ignore). */
66
+ sort: SortState | null;
67
+ /** Page size for this fetch (validated against `pageSizeOptions` upstream). */
68
+ pageSize: number;
69
+ /** Opaque forward cursor for this fetch (CMS decodes it to an offset). */
70
+ afterCursor: string | undefined;
71
+ /**
72
+ * CMS-only, optional. Present (non-null, non-empty) only when content types
73
+ * are known/selected; absent on the bootstrap call so the CMS adapter omits
74
+ * `$cmsContentTypeFQNs` entirely.
75
+ */
76
+ contentTypes?: string[] | null;
77
+ /**
78
+ * CMS-only, optional. The resolved UIBundle id fed into the CMS search
79
+ * query. Resolved by the caller (async `getUIBundleId()`) BEFORE the sync
80
+ * query builder runs, then threaded in here so `buildRequest` stays
81
+ * synchronous. Ignored by non-CMS adapters.
82
+ */
83
+ uiBundleId?: string;
84
+ }
85
+
86
+ /**
87
+ * Runtime adapter for one source `kind`. Bridges a kind-agnostic
88
+ * {@link SourceRequest} to a GraphQL fragment and back to a {@link SourceResult}.
89
+ */
90
+ export interface SourceAdapter<T extends SourceConfig = SourceConfig> {
91
+ /** The `kind` discriminator this adapter handles. */
92
+ readonly kind: T["kind"];
93
+ /** Build this source's fragment/variables contribution for the combined doc. */
94
+ buildRequest(request: SourceRequest<T>): QueryFragmentContribution;
95
+ /**
96
+ * Extract this source's result from the full response `data` root. Returns
97
+ * `null` when the source's own subtree is absent or errored — never throws
98
+ * for a partial failure.
99
+ */
100
+ parseResponse(root: Record<string, unknown>, request: SourceRequest<T>): SourceResult | null;
101
+ }
@@ -1,35 +1,91 @@
1
1
  /**
2
- * Executes a search request and parses results into a per-source map.
2
+ * Executes a combined search request and parses each source's subtree via its
3
+ * adapter, with partial-failure resilience.
3
4
  *
4
- * Today there's only one adapter — SObjects via the platform-sdk uiapi GraphQL
5
- * bridge. Future adapters (CMS, REST, etc.) can run alongside this one and
6
- * have their results merged into the same `Record<sourceKey, SourceResult>`
7
- * by the hook.
5
+ * The combined GraphQL response can carry BOTH partial `data` AND `errors`
6
+ * (`uiapi` and CMS `managed_content` are independent roots). Rather than
7
+ * throwing on `response.errors`, we parse each source through
8
+ * `getAdapter(kind).parseResponse(root, req)`:
9
+ * - a `SourceResult` → recorded in `results[key]`.
10
+ * - `null` (subtree absent/errored) → the source produced no data; if the
11
+ * request as a whole reported GraphQL errors, an attributed message is
12
+ * recorded in `errors[key]` so the UI can show a per-source error while
13
+ * sibling sources still render.
14
+ *
15
+ * Only a whole-request failure — the SDK call rejecting, or a response with no
16
+ * `data` at all — throws, so `useSearch` surfaces it as a page-level error
17
+ * (preserving the original network-failure behaviour).
8
18
  */
9
19
 
10
20
  import { createDataSDK } from "@salesforce/platform-sdk";
11
- import { buildSearchQuery, type SourceRequest } from "../queryBuilder";
21
+ import { buildSearchQuery } from "../queryBuilder";
22
+ import { getAdapter } from "../adapters/registry";
23
+ import type { SourceRequest } from "../adapters/types";
12
24
  import type { SourceResult } from "../types";
13
25
 
14
- interface RawSourceResult {
15
- edges?: Array<{ node?: unknown }> | null;
16
- pageInfo?: {
17
- hasNextPage?: boolean | null;
18
- hasPreviousPage?: boolean | null;
19
- startCursor?: string | null;
20
- endCursor?: string | null;
21
- } | null;
22
- totalCount?: number | null;
26
+ /**
27
+ * The outcome of a combined `runSearch`: successfully-parsed results plus a
28
+ * per-source error map. `errors` is keyed by `source.key` and only carries an
29
+ * entry for a source that failed to produce data (its subtree was absent or
30
+ * errored). A source appears in at most one of `results` / `errors`.
31
+ */
32
+ export interface RunSearchOutcome {
33
+ results: Record<string, SourceResult>;
34
+ errors: Record<string, string>;
35
+ }
36
+
37
+ /** Minimal shape of a GraphQL error we can attribute to a source by path. */
38
+ interface GraphQLErrorLike {
39
+ message?: string | null;
40
+ path?: Array<string | number> | null;
23
41
  }
24
42
 
25
43
  /**
26
- * Runs a single multi-aliased GraphQL request against uiapi.query.
44
+ * Attributes GraphQL errors to a source by matching the error `path` against
45
+ * the source's location in the document:
46
+ * - SObject aliases live under `uiapi.query.<key>`.
47
+ * - CMS lives under the root `managed_content` block.
27
48
  *
28
- * Returns a plain object keyed by source.key. A source whose alias is missing
29
- * from the response (e.g. due to a partial GraphQL error) is omitted; the
30
- * caller can detect this by `result[sourceKey] == null`.
49
+ * Returns the joined messages whose `path` matches `request`. When NO path
50
+ * matches (or paths are absent), returns a GENERIC per-source message rather
51
+ * than echoing unrelated sources' messages — otherwise one source could display
52
+ * another's error text (over-broad cross-attribution). The whole-request
53
+ * (`data == null`) path still joins ALL error messages; that lives in
54
+ * `runSearch` and is unaffected.
31
55
  */
32
- export async function runSearch(requests: SourceRequest[]): Promise<Record<string, SourceResult>> {
56
+ function attributeError(request: SourceRequest, errors: GraphQLErrorLike[]): string {
57
+ const kind = request.source.kind;
58
+ const key = request.source.key;
59
+
60
+ const matched = errors.filter((e) => {
61
+ const path = e.path;
62
+ if (!path || path.length === 0) return false;
63
+ if (kind === "cms") {
64
+ return path[0] === "managed_content";
65
+ }
66
+ // SObject: uiapi -> query -> <key>
67
+ return path[0] === "uiapi" && path[1] === "query" && path[2] === key;
68
+ });
69
+
70
+ // No path matched this source: don't leak sibling sources' messages — fall
71
+ // back to a generic per-source explanation.
72
+ if (matched.length === 0) {
73
+ return "Search failed for this source.";
74
+ }
75
+
76
+ const message = matched
77
+ .map((e) => e.message)
78
+ .filter((m): m is string => !!m)
79
+ .join("; ");
80
+ return message || "Search failed for this source.";
81
+ }
82
+
83
+ /**
84
+ * Runs one combined multi-source GraphQL request. Returns `{ results, errors }`
85
+ * (never throws on partial GraphQL errors); throws only on a whole-request /
86
+ * network failure.
87
+ */
88
+ export async function runSearch(requests: SourceRequest[]): Promise<RunSearchOutcome> {
33
89
  const { document, variables } = buildSearchQuery(requests);
34
90
 
35
91
  const data = await createDataSDK();
@@ -38,34 +94,43 @@ export async function runSearch(requests: SourceRequest[]): Promise<Record<strin
38
94
  variables,
39
95
  });
40
96
 
41
- if (response.errors?.length) {
42
- throw new Error(response.errors.map((e) => e.message).join("; "));
97
+ const root = response.data as Record<string, unknown> | undefined;
98
+ const gqlErrors = (response.errors ?? []) as GraphQLErrorLike[];
99
+
100
+ // Whole-request failure: no data at all. Surface as a page-level error
101
+ // (preserves the original network-failure behaviour).
102
+ if (root == null) {
103
+ const message = gqlErrors
104
+ .map((e) => e.message)
105
+ .filter((m): m is string => !!m)
106
+ .join("; ");
107
+ throw new Error(message || "Unified search failed");
43
108
  }
44
109
 
45
- const root = response.data as Record<string, unknown> | undefined;
46
- const queryRoot = (root?.uiapi as Record<string, unknown> | undefined)?.query as
47
- | Record<string, RawSourceResult | null | undefined>
48
- | undefined;
110
+ const results: Record<string, SourceResult> = {};
111
+ const errors: Record<string, string> = {};
49
112
 
50
- const out: Record<string, SourceResult> = {};
51
113
  for (const request of requests) {
52
- const raw = queryRoot?.[request.source.key];
53
- if (raw == null) continue;
54
- const nodes = (raw.edges ?? [])
55
- .map((edge) => edge?.node)
56
- .filter((node): node is unknown => node != null);
57
- out[request.source.key] = {
58
- nodes,
59
- pageInfo: raw.pageInfo
60
- ? {
61
- hasNextPage: raw.pageInfo.hasNextPage ?? false,
62
- hasPreviousPage: raw.pageInfo.hasPreviousPage ?? false,
63
- startCursor: raw.pageInfo.startCursor ?? null,
64
- endCursor: raw.pageInfo.endCursor ?? null,
65
- }
66
- : null,
67
- totalCount: raw.totalCount ?? null,
68
- };
114
+ const key = request.source.key;
115
+ let parsed: SourceResult | null = null;
116
+ try {
117
+ parsed = getAdapter(request.source.kind).parseResponse(root, request);
118
+ } catch {
119
+ // An adapter that throws while parsing its own subtree is a
120
+ // per-source failure, not a page-level one.
121
+ parsed = null;
122
+ }
123
+
124
+ if (parsed != null) {
125
+ results[key] = parsed;
126
+ } else if (gqlErrors.length > 0) {
127
+ // No data for this source AND the request reported errors — attribute
128
+ // a message so the UI can show a per-source error.
129
+ errors[key] = attributeError(request, gqlErrors);
130
+ }
131
+ // else: no data and no errors — the source simply returned nothing;
132
+ // leave it absent from both maps (empty result, no error).
69
133
  }
70
- return out;
134
+
135
+ return { results, errors };
71
136
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GlobalSearchBox — pure launcher component for global search.
3
+ *
4
+ * A self-contained search input + button that navigates to `/search?q=...`
5
+ * when the user submits a query of at least MIN_QUERY_LENGTH characters.
6
+ *
7
+ * Props are purely cosmetic (className, placeholder, autoFocus) — all behavior
8
+ * (the gate, the route, the query key) is owned by this component, not the caller.
9
+ *
10
+ * Usage:
11
+ * ```tsx
12
+ * import { GlobalSearchBox } from "../features/search";
13
+ * <GlobalSearchBox className="mb-8" placeholder="Search everything..." />
14
+ * ```
15
+ */
16
+
17
+ import { Search } from "lucide-react";
18
+ import { useId, useState } from "react";
19
+ import { useNavigate } from "react-router";
20
+ import { Input } from "../../../components/ui/input";
21
+ import { Button } from "../../../components/ui/button";
22
+ import { GLOBAL_QUERY_KEY } from "../utils/filterUtils";
23
+ import { MIN_QUERY_LENGTH } from "../constants";
24
+
25
+ export interface GlobalSearchBoxProps {
26
+ /** Optional CSS class for container styling/placement. */
27
+ className?: string;
28
+ /** Placeholder text for the search input. */
29
+ placeholder?: string;
30
+ /** Whether the input should autofocus on mount. */
31
+ autoFocus?: boolean;
32
+ /**
33
+ * Accessible (screen-reader) label for the input. Exposed as a prop so
34
+ * callers can localize it; defaults to English. */
35
+ label?: string;
36
+ /** Visible text for the submit button. Exposed for localization. */
37
+ submitLabel?: string;
38
+ /**
39
+ * Minimum length of keyword required by search. `{min}` is replaced with
40
+ * {@link MIN_QUERY_LENGTH}. Exposed for localization. */
41
+ minLengthHint?: string;
42
+ }
43
+
44
+ export function GlobalSearchBox({
45
+ className,
46
+ placeholder = "Search…",
47
+ autoFocus,
48
+ label = "Search",
49
+ submitLabel = "Search",
50
+ minLengthHint = "Enter at least {min} characters to search.",
51
+ }: GlobalSearchBoxProps) {
52
+ const navigate = useNavigate();
53
+ const [text, setText] = useState("");
54
+ const inputId = useId();
55
+ const hintId = useId();
56
+
57
+ const canSearch = text.trim().length >= MIN_QUERY_LENGTH;
58
+ const hintText = minLengthHint.replace("{min}", String(MIN_QUERY_LENGTH));
59
+
60
+ const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
61
+ e.preventDefault();
62
+ if (!canSearch) return;
63
+ const params = `?${GLOBAL_QUERY_KEY}=${encodeURIComponent(text.trim())}`;
64
+ navigate(`/search${params}`);
65
+ };
66
+
67
+ return (
68
+ <form onSubmit={handleSubmit} className={className} role="search">
69
+ <label htmlFor={inputId} className="sr-only">
70
+ {label}
71
+ </label>
72
+ <div className="flex gap-2">
73
+ <div className="relative flex-1">
74
+ <Search
75
+ aria-hidden="true"
76
+ className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
77
+ />
78
+ <Input
79
+ id={inputId}
80
+ type="text"
81
+ value={text}
82
+ onChange={(e) => setText(e.target.value)}
83
+ placeholder={placeholder}
84
+ autoFocus={autoFocus}
85
+ className="pl-9"
86
+ aria-describedby={hintId}
87
+ />
88
+ </div>
89
+ <Button type="submit" disabled={!canSearch}>
90
+ {submitLabel}
91
+ </Button>
92
+ </div>
93
+ <p id={hintId} className="sr-only">
94
+ {hintText}
95
+ </p>
96
+ </form>
97
+ );
98
+ }
@@ -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>