@salesforce/ui-bundle-template-app-react-sample-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/propertymanagementapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/README.md +235 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/__tests__/queryBuilder.test.ts +166 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/channelResolver.test.ts +73 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +127 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeSessionCache.test.ts +114 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeUtils.test.ts +82 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/orgApiVersionService.test.ts +57 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/parseResponse.test.ts +102 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +98 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchableContentTypesService.test.ts +153 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +54 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/types.ts +66 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/registry.ts +38 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/index.ts +19 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +101 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/__tests__/searchService.test.ts +304 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +110 -45
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +34 -17
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +31 -13
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +17 -11
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +9 -4
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/Search.test.tsx +173 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/CmsResultRow.test.tsx +139 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/resolveResultRenderer.test.ts +104 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +7 -2
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/constants.ts +8 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +313 -50
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +26 -1
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +62 -118
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +74 -5
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- 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
|
|
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
|
+
}
|