@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
|
@@ -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
|
+
}
|
package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts
ADDED
|
@@ -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
|
|
2
|
+
* Executes a combined search request and parses each source's subtree via its
|
|
3
|
+
* adapter, with partial-failure resilience.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
const
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 {
|
|
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>
|