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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/__tests__/queryBuilder.test.ts +166 -0
  5. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/channelResolver.test.ts +73 -0
  6. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +127 -0
  7. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeSessionCache.test.ts +114 -0
  8. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeUtils.test.ts +82 -0
  9. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/orgApiVersionService.test.ts +57 -0
  10. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/parseResponse.test.ts +102 -0
  11. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +98 -0
  12. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchableContentTypesService.test.ts +153 -0
  13. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  14. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  15. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  16. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  17. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  18. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  19. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  20. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  21. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +54 -0
  22. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  23. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  24. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/types.ts +66 -0
  25. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/registry.ts +38 -0
  26. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/index.ts +19 -0
  27. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  28. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  29. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +101 -0
  30. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/__tests__/searchService.test.ts +304 -0
  31. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +110 -45
  32. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  33. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  34. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +31 -13
  35. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +17 -11
  36. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +9 -4
  37. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/Search.test.tsx +173 -0
  38. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  39. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  40. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/CmsResultRow.test.tsx +139 -0
  41. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/resolveResultRenderer.test.ts +104 -0
  42. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  43. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +7 -2
  44. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/constants.ts +8 -0
  45. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +313 -50
  46. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +26 -1
  47. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +62 -118
  48. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +74 -5
  49. package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
  50. package/dist/package-lock.json +2 -2
  51. package/dist/package.json +1 -1
  52. package/package.json +2 -2
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ /**
8
+ * Tests for runSearch partial-failure resilience: one source erroring should
9
+ * not drop the other source's results; errors are returned per-source rather
10
+ * than throwing at the page level.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach } from "vitest";
13
+ import type { SourceRequest } from "../../adapters/types";
14
+
15
+ // Mock the platform SDK and adapters
16
+ vi.mock("@salesforce/platform-sdk", () => ({
17
+ createDataSDK: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("../../adapters/registry", () => ({
21
+ getAdapter: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("../../queryBuilder", () => ({
25
+ buildSearchQuery: vi.fn(),
26
+ }));
27
+
28
+ describe("runSearch", () => {
29
+ let runSearch: typeof import("../searchService").runSearch;
30
+ let mockGraphqlQuery: ReturnType<typeof vi.fn>;
31
+ let mockGetAdapter: ReturnType<typeof vi.fn>;
32
+ let mockBuildSearchQuery: ReturnType<typeof vi.fn>;
33
+
34
+ beforeEach(async () => {
35
+ vi.resetModules();
36
+
37
+ const { createDataSDK } = await import("@salesforce/platform-sdk");
38
+ const { getAdapter } = await import("../../adapters/registry");
39
+ const { buildSearchQuery } = await import("../../queryBuilder");
40
+ const { runSearch: importedRunSearch } = await import("../searchService");
41
+
42
+ runSearch = importedRunSearch;
43
+ mockGraphqlQuery = vi.fn();
44
+ mockGetAdapter = getAdapter as ReturnType<typeof vi.fn>;
45
+ mockBuildSearchQuery = buildSearchQuery as ReturnType<typeof vi.fn>;
46
+
47
+ (createDataSDK as ReturnType<typeof vi.fn>).mockResolvedValue({
48
+ graphql: {
49
+ query: mockGraphqlQuery,
50
+ },
51
+ });
52
+
53
+ mockBuildSearchQuery.mockReturnValue({
54
+ document: "query Search { ... }",
55
+ variables: {},
56
+ });
57
+ });
58
+
59
+ it("returns both results when both sources succeed", async () => {
60
+ const mockSobjectAdapter = {
61
+ kind: "sobject",
62
+ buildRequest: vi.fn(),
63
+ parseResponse: vi.fn().mockReturnValue({
64
+ nodes: [{ Id: "001", Name: "Acme" }],
65
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
66
+ totalCount: 1,
67
+ }),
68
+ };
69
+
70
+ const mockCmsAdapter = {
71
+ kind: "cms",
72
+ buildRequest: vi.fn(),
73
+ parseResponse: vi.fn().mockReturnValue({
74
+ nodes: [{ managedContentId: "mc1", title: "Annual Report" }],
75
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
76
+ totalCount: 1,
77
+ }),
78
+ };
79
+
80
+ mockGetAdapter.mockImplementation((kind: string) => {
81
+ if (kind === "sobject") return mockSobjectAdapter;
82
+ if (kind === "cms") return mockCmsAdapter;
83
+ throw new Error(`Unknown kind: ${kind}`);
84
+ });
85
+
86
+ mockGraphqlQuery.mockResolvedValue({
87
+ data: {
88
+ uiapi: { query: { accounts: { edges: [] } } },
89
+ managed_content: { search: { searchContentInChannels: {} } },
90
+ },
91
+ errors: undefined,
92
+ });
93
+
94
+ const outcome = await runSearch([
95
+ { source: { kind: "sobject", key: "accounts" } },
96
+ { source: { kind: "cms", key: "content" } },
97
+ ] as unknown as SourceRequest[]);
98
+
99
+ expect(outcome.results).toHaveProperty("accounts");
100
+ expect(outcome.results).toHaveProperty("content");
101
+ expect(outcome.errors).toEqual({});
102
+ });
103
+
104
+ it("partial failure: CMS fails, SObject succeeds — returns SObject results + CMS error", async () => {
105
+ const mockSobjectAdapter = {
106
+ kind: "sobject",
107
+ buildRequest: vi.fn(),
108
+ parseResponse: vi.fn().mockReturnValue({
109
+ nodes: [{ Id: "001", Name: "Acme" }],
110
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
111
+ totalCount: 1,
112
+ }),
113
+ };
114
+
115
+ const mockCmsAdapter = {
116
+ kind: "cms",
117
+ buildRequest: vi.fn(),
118
+ parseResponse: vi.fn().mockReturnValue(null), // CMS failed to parse
119
+ };
120
+
121
+ mockGetAdapter.mockImplementation((kind: string) => {
122
+ if (kind === "sobject") return mockSobjectAdapter;
123
+ if (kind === "cms") return mockCmsAdapter;
124
+ throw new Error(`Unknown kind: ${kind}`);
125
+ });
126
+
127
+ mockGraphqlQuery.mockResolvedValue({
128
+ data: {
129
+ uiapi: { query: { accounts: { edges: [] } } },
130
+ managed_content: null, // CMS subtree absent/errored
131
+ },
132
+ errors: [{ message: "CMS search failed", path: ["managed_content"] }],
133
+ });
134
+
135
+ const outcome = await runSearch([
136
+ { source: { kind: "sobject", key: "accounts" } },
137
+ { source: { kind: "cms", key: "content" } },
138
+ ] as unknown as SourceRequest[]);
139
+
140
+ // SObject results present
141
+ expect(outcome.results).toHaveProperty("accounts");
142
+ expect(outcome.results.accounts.nodes).toHaveLength(1);
143
+
144
+ // CMS error attributed
145
+ expect(outcome.errors).toHaveProperty("content");
146
+ expect(outcome.errors.content).toContain("CMS search failed");
147
+
148
+ // Not a page-level throw
149
+ expect(Object.keys(outcome.results)).toContain("accounts");
150
+ });
151
+
152
+ it("partial failure: SObject fails, CMS succeeds — returns CMS results + SObject error", async () => {
153
+ const mockSobjectAdapter = {
154
+ kind: "sobject",
155
+ buildRequest: vi.fn(),
156
+ parseResponse: vi.fn().mockReturnValue(null), // SObject failed
157
+ };
158
+
159
+ const mockCmsAdapter = {
160
+ kind: "cms",
161
+ buildRequest: vi.fn(),
162
+ parseResponse: vi.fn().mockReturnValue({
163
+ nodes: [{ managedContentId: "mc1", title: "Report" }],
164
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
165
+ totalCount: 1,
166
+ }),
167
+ };
168
+
169
+ mockGetAdapter.mockImplementation((kind: string) => {
170
+ if (kind === "sobject") return mockSobjectAdapter;
171
+ if (kind === "cms") return mockCmsAdapter;
172
+ throw new Error(`Unknown kind: ${kind}`);
173
+ });
174
+
175
+ mockGraphqlQuery.mockResolvedValue({
176
+ data: {
177
+ uiapi: { query: { accounts: null } },
178
+ managed_content: { search: { searchContentInChannels: {} } },
179
+ },
180
+ errors: [{ message: "Account query failed", path: ["uiapi", "query", "accounts"] }],
181
+ });
182
+
183
+ const outcome = await runSearch([
184
+ { source: { kind: "sobject", key: "accounts" } },
185
+ { source: { kind: "cms", key: "content" } },
186
+ ] as unknown as SourceRequest[]);
187
+
188
+ // CMS results present
189
+ expect(outcome.results).toHaveProperty("content");
190
+ expect(outcome.results.content.nodes).toHaveLength(1);
191
+
192
+ // SObject error attributed
193
+ expect(outcome.errors).toHaveProperty("accounts");
194
+ expect(outcome.errors.accounts).toContain("Account query failed");
195
+
196
+ // Not a page-level throw
197
+ expect(Object.keys(outcome.results)).toContain("content");
198
+ });
199
+
200
+ it("whole-request failure (no data at all) throws for page-level error", async () => {
201
+ mockGraphqlQuery.mockResolvedValue({
202
+ data: null, // Whole request failed
203
+ errors: [{ message: "Network timeout" }],
204
+ });
205
+
206
+ await expect(
207
+ runSearch([{ source: { kind: "sobject", key: "accounts" } }] as unknown as SourceRequest[]),
208
+ ).rejects.toThrow("Network timeout");
209
+ });
210
+
211
+ it("content-type validation error (data:{}, empty paths) is a per-source error, not a page throw", async () => {
212
+ // Regression: the discovery-driven CMS refetch adds `contentTypes:` to
213
+ // `searchContentInChannels`. On an org whose backend doesn't accept that
214
+ // argument, the response is `{ data: {}, errors: [ValidationError …] }`
215
+ // with an EMPTY `paths` array. Because `data` is `{}` (not null) runSearch
216
+ // must NOT throw; it records a generic per-source error for the CMS source
217
+ // so the caller can degrade to the un-narrowed bootstrap results.
218
+ const mockCmsAdapter = {
219
+ kind: "cms",
220
+ buildRequest: vi.fn(),
221
+ parseResponse: vi.fn().mockReturnValue(null), // {} has no CMS subtree
222
+ };
223
+ mockGetAdapter.mockReturnValue(mockCmsAdapter);
224
+
225
+ mockGraphqlQuery.mockResolvedValue({
226
+ data: {},
227
+ errors: [
228
+ {
229
+ extensions: { ErrorType: "ValidationError" },
230
+ message:
231
+ "Validation error (UnknownArgument@[managed_content/search/searchContentInChannels]) : Unknown field argument 'contentTypes'",
232
+ paths: [],
233
+ },
234
+ ],
235
+ });
236
+
237
+ const outcome = await runSearch([
238
+ { source: { kind: "cms", key: "content" } },
239
+ ] as unknown as SourceRequest[]);
240
+
241
+ // No throw, no parsed result, and a generic per-source error (empty
242
+ // `paths` → no attribution match → the generic fallback message).
243
+ expect(outcome.results).toEqual({});
244
+ expect(outcome.errors.content).toBe("Search failed for this source.");
245
+ });
246
+
247
+ it("attributes errors correctly by path for multiple SObject sources", async () => {
248
+ const mockSobjectAdapter = {
249
+ kind: "sobject",
250
+ buildRequest: vi.fn(),
251
+ parseResponse: vi.fn().mockReturnValue(null),
252
+ };
253
+
254
+ mockGetAdapter.mockReturnValue(mockSobjectAdapter);
255
+
256
+ mockGraphqlQuery.mockResolvedValue({
257
+ data: {
258
+ uiapi: { query: { accounts: null, contacts: null } },
259
+ },
260
+ errors: [
261
+ { message: "Account error", path: ["uiapi", "query", "accounts"] },
262
+ { message: "Contact error", path: ["uiapi", "query", "contacts"] },
263
+ ],
264
+ });
265
+
266
+ const outcome = await runSearch([
267
+ { source: { kind: "sobject", key: "accounts" } },
268
+ { source: { kind: "sobject", key: "contacts" } },
269
+ ] as unknown as SourceRequest[]);
270
+
271
+ expect(outcome.errors.accounts).toContain("Account error");
272
+ expect(outcome.errors.contacts).toContain("Contact error");
273
+ expect(outcome.results).toEqual({});
274
+ });
275
+
276
+ it("does not cross-attribute: a source with no matching error path gets a generic message", async () => {
277
+ const mockSobjectAdapter = {
278
+ kind: "sobject",
279
+ buildRequest: vi.fn(),
280
+ parseResponse: vi.fn().mockReturnValue(null),
281
+ };
282
+
283
+ mockGetAdapter.mockReturnValue(mockSobjectAdapter);
284
+
285
+ // Only `accounts` has a matching error path; `contacts` produced no data
286
+ // but no error path points at it. It must NOT echo the accounts message.
287
+ mockGraphqlQuery.mockResolvedValue({
288
+ data: {
289
+ uiapi: { query: { accounts: null, contacts: null } },
290
+ },
291
+ errors: [{ message: "Account error", path: ["uiapi", "query", "accounts"] }],
292
+ });
293
+
294
+ const outcome = await runSearch([
295
+ { source: { kind: "sobject", key: "accounts" } },
296
+ { source: { kind: "sobject", key: "contacts" } },
297
+ ] as unknown as SourceRequest[]);
298
+
299
+ expect(outcome.errors.accounts).toContain("Account error");
300
+ // The generic per-source fallback — NOT the unrelated "Account error".
301
+ expect(outcome.errors.contacts).toBe("Search failed for this source.");
302
+ expect(outcome.errors.contacts).not.toContain("Account error");
303
+ });
304
+ });
@@ -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
+ }