@salesforce/ui-bundle-template-app-react-sample-b2e 11.55.1 → 11.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS response parser: `root.managed_content.search.searchContentInChannels` →
|
|
3
|
+
* {@link SourceResult}, normalising CMS offset paging onto the common
|
|
4
|
+
* cursor-shaped {@link SourcePageInfo}.
|
|
5
|
+
*
|
|
6
|
+
* Partial-failure leniency: every level is guarded and the function returns
|
|
7
|
+
* `null` — never throws — when its subtree is absent or errored.
|
|
8
|
+
*
|
|
9
|
+
* Offset → pageInfo:
|
|
10
|
+
* - `totalCount = total`
|
|
11
|
+
* - `hasNextPage = offset + items.length < total`
|
|
12
|
+
* - `hasPreviousPage= offset > 0`
|
|
13
|
+
* - `endCursor = String(offset + pageSize)` (the caller's request page size)
|
|
14
|
+
* - `startCursor = String(offset)`
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CmsSourceConfig, CmsSearchResponse } from "./types";
|
|
18
|
+
import type { SourceResult } from "../../types";
|
|
19
|
+
import type { SourceRequest } from "../types";
|
|
20
|
+
|
|
21
|
+
/** Narrows an unknown value to a plain object (or `undefined`). */
|
|
22
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
23
|
+
return value != null && typeof value === "object"
|
|
24
|
+
? (value as Record<string, unknown>)
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads the CMS subtree from the full response `data` root and maps it to a
|
|
30
|
+
* {@link SourceResult}. Returns `null` when the subtree is absent/errored.
|
|
31
|
+
*
|
|
32
|
+
* `pageSize` for the cursor math is taken from the request (the page size we
|
|
33
|
+
* asked for), matching `buildRequest`'s `$cmsLimit = request.pageSize`, so
|
|
34
|
+
* next/prev advances the offset by exactly one page.
|
|
35
|
+
*/
|
|
36
|
+
export function parseCmsResponse(
|
|
37
|
+
root: Record<string, unknown>,
|
|
38
|
+
request: SourceRequest<CmsSourceConfig>,
|
|
39
|
+
): SourceResult | null {
|
|
40
|
+
const managedContent = asRecord(root?.managed_content);
|
|
41
|
+
const search = asRecord(managedContent?.search);
|
|
42
|
+
const raw = asRecord(search?.searchContentInChannels);
|
|
43
|
+
if (!raw) return null;
|
|
44
|
+
|
|
45
|
+
const response = raw as unknown as CmsSearchResponse;
|
|
46
|
+
|
|
47
|
+
const items = Array.isArray(response.items) ? response.items : [];
|
|
48
|
+
const total = typeof response.total === "number" ? response.total : null;
|
|
49
|
+
const offset = typeof response.offset === "number" ? response.offset : 0;
|
|
50
|
+
const pageSize = request.pageSize;
|
|
51
|
+
|
|
52
|
+
const hasNextPage = total != null ? offset + items.length < total : false;
|
|
53
|
+
const hasPreviousPage = offset > 0;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
nodes: items,
|
|
57
|
+
pageInfo: {
|
|
58
|
+
hasNextPage,
|
|
59
|
+
hasPreviousPage,
|
|
60
|
+
startCursor: String(offset),
|
|
61
|
+
endCursor: String(offset + pageSize),
|
|
62
|
+
},
|
|
63
|
+
totalCount: total,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getCurrentApp } from "@salesforce/platform-sdk";
|
|
2
|
+
|
|
3
|
+
/** Returns the UIBundle id for CMS search, resolved from the running app. */
|
|
4
|
+
export async function getUIBundleId(): Promise<string> {
|
|
5
|
+
const app = await getCurrentApp();
|
|
6
|
+
return app.identity?.bundleId ?? "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Shape guard for a UIBundle record id (9YE + 12-15 alphanumerics). */
|
|
10
|
+
export const UI_BUNDLE_ID_PATTERN = /^9YE[a-zA-Z0-9]{12,15}$/;
|
|
11
|
+
|
|
12
|
+
/** True when `id` is a well-formed (9YE-shaped) UIBundle id. */
|
|
13
|
+
export function isConfiguredUIBundleId(id: string): boolean {
|
|
14
|
+
return UI_BUNDLE_ID_PATTERN.test(id.trim());
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS source config + the response shapes returned by
|
|
3
|
+
* `managed_content.search.searchContentInChannels`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface CmsChannelDetails {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
type?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CmsChannelDeliveryDetails {
|
|
13
|
+
contentUrl?: string;
|
|
14
|
+
publishedDate?: string;
|
|
15
|
+
managedContentChannelDetails: CmsChannelDetails;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CmsSearchItem {
|
|
19
|
+
managedContentId: string;
|
|
20
|
+
managedContentKey: string;
|
|
21
|
+
title: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
language?: string;
|
|
24
|
+
highlightedSnippet?: string;
|
|
25
|
+
managedContentChannelDeliveryDetails: CmsChannelDeliveryDetails[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CmsSearchResponse {
|
|
29
|
+
total: number;
|
|
30
|
+
offset: number;
|
|
31
|
+
pageSize: number;
|
|
32
|
+
items: CmsSearchItem[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Configuration for one CMS source in a search experience.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately minimal — unlike an SObject source (which hardcodes an object
|
|
39
|
+
* name, searchable/display fields, filters, etc.), a CMS source hardcodes
|
|
40
|
+
* nothing about the CMS data. The search channel id comes from
|
|
41
|
+
* `searchChannel.getUIBundleId()`; content types are discovered at runtime from
|
|
42
|
+
* the search results. This config only names the source and (optionally)
|
|
43
|
+
* describes how to build a result link.
|
|
44
|
+
*/
|
|
45
|
+
export interface CmsSourceConfig {
|
|
46
|
+
/** Discriminator — selects the CMS runtime adapter. */
|
|
47
|
+
kind: "cms";
|
|
48
|
+
/**
|
|
49
|
+
* Stable identifier. Used as the scope key, the result-map key, and the
|
|
50
|
+
* prefix for CMS content-type scope entries (`"<key>:<fqn>"`). Must match
|
|
51
|
+
* `/^[A-Za-z_][A-Za-z0-9_]*$/`.
|
|
52
|
+
*/
|
|
53
|
+
key: string;
|
|
54
|
+
/** Plural display label — dropdown entry + section header, e.g. "Content". */
|
|
55
|
+
label: string;
|
|
56
|
+
/** Singular label for the per-card source badge. Falls back to `label`. */
|
|
57
|
+
labelSingular?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Optional URL pattern used by `CmsResultRow` to build a result link.
|
|
60
|
+
* Tokens: `:id` (managedContentId), `:key` (managedContentKey),
|
|
61
|
+
* `:contentType` (contentType). When omitted, the row falls back to
|
|
62
|
+
* `/content/:contentType/:id`.
|
|
63
|
+
*/
|
|
64
|
+
routePattern?: string;
|
|
65
|
+
// NO channelId, NO content types, NO displayFields — all resolved at runtime.
|
|
66
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter registry — maps a source `kind` to its runtime {@link SourceAdapter}.
|
|
3
|
+
*
|
|
4
|
+
* The core query builder and response parser call {@link getAdapter} for each
|
|
5
|
+
* source instead of hard-coding SObject logic, so a new backend is added purely
|
|
6
|
+
* by registering an adapter here (plus its `kind` in the `SourceConfig` union).
|
|
7
|
+
*
|
|
8
|
+
* The `cmsAdapter` is authored by the CMS implementer at `./cms` (exported as
|
|
9
|
+
* `cmsAdapter`); it is imported and registered here, never stubbed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SourceConfig } from "../types";
|
|
13
|
+
import type { SourceAdapter } from "./types";
|
|
14
|
+
import { sObjectAdapter } from "./sobject";
|
|
15
|
+
import { cmsAdapter } from "./cms";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Registry keyed by `SourceConfig["kind"]`. Each entry is the adapter narrowed
|
|
19
|
+
* to the matching config variant; `getAdapter` widens back to the base
|
|
20
|
+
* `SourceAdapter` for kind-agnostic callers.
|
|
21
|
+
*/
|
|
22
|
+
const REGISTRY: { [K in SourceConfig["kind"]]: SourceAdapter<Extract<SourceConfig, { kind: K }>> } =
|
|
23
|
+
{
|
|
24
|
+
sobject: sObjectAdapter,
|
|
25
|
+
cms: cmsAdapter,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the adapter registered for `kind`. Throws on an unknown kind (a
|
|
30
|
+
* config carrying a `kind` with no adapter is a programming error).
|
|
31
|
+
*/
|
|
32
|
+
export function getAdapter(kind: SourceConfig["kind"]): SourceAdapter {
|
|
33
|
+
const adapter = REGISTRY[kind];
|
|
34
|
+
if (!adapter) {
|
|
35
|
+
throw new Error(`No search adapter registered for source kind "${kind}".`);
|
|
36
|
+
}
|
|
37
|
+
return adapter as SourceAdapter;
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SObject adapter — the original uiapi GraphQL bridge, now behind the
|
|
3
|
+
* kind-agnostic {@link SourceAdapter} contract.
|
|
4
|
+
*
|
|
5
|
+
* `buildRequest` delegates to the extracted fragment builder and
|
|
6
|
+
* `parseResponse` to the extracted response parser. Both preserve the original
|
|
7
|
+
* behaviour byte-for-byte; this file is only the wiring.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SObjectSourceConfig } from "../../types";
|
|
11
|
+
import type { SourceAdapter } from "../types";
|
|
12
|
+
import { buildSObjectFragment } from "./queryFragment";
|
|
13
|
+
import { parseSObjectResponse } from "./parseResponse";
|
|
14
|
+
|
|
15
|
+
export const sObjectAdapter: SourceAdapter<SObjectSourceConfig> = {
|
|
16
|
+
kind: "sobject",
|
|
17
|
+
buildRequest: buildSObjectFragment,
|
|
18
|
+
parseResponse: parseSObjectResponse,
|
|
19
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SObject response parser.
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `searchService.ts`: the `RawSourceResult` interface
|
|
5
|
+
* and the `edges → nodes / pageInfo / totalCount` mapping. Reads its subtree
|
|
6
|
+
* from `root.uiapi.query[source.key]` and returns `null` when the alias is
|
|
7
|
+
* absent (partial-failure leniency), never throwing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SObjectSourceConfig, SourceResult } from "../../types";
|
|
11
|
+
import type { SourceRequest } from "../types";
|
|
12
|
+
|
|
13
|
+
interface RawSourceResult {
|
|
14
|
+
edges?: Array<{ node?: unknown }> | null;
|
|
15
|
+
pageInfo?: {
|
|
16
|
+
hasNextPage?: boolean | null;
|
|
17
|
+
hasPreviousPage?: boolean | null;
|
|
18
|
+
startCursor?: string | null;
|
|
19
|
+
endCursor?: string | null;
|
|
20
|
+
} | null;
|
|
21
|
+
totalCount?: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Maps `root.uiapi.query[source.key]` (an `edges`/`pageInfo`/`totalCount`
|
|
26
|
+
* subtree) to a {@link SourceResult}. Returns `null` when the alias is missing
|
|
27
|
+
* from the response (e.g. a partial GraphQL error blanked this source only).
|
|
28
|
+
*/
|
|
29
|
+
export function parseSObjectResponse(
|
|
30
|
+
root: Record<string, unknown>,
|
|
31
|
+
request: SourceRequest<SObjectSourceConfig>,
|
|
32
|
+
): SourceResult | null {
|
|
33
|
+
const queryRoot = (root?.uiapi as Record<string, unknown> | undefined)?.query as
|
|
34
|
+
| Record<string, RawSourceResult | null | undefined>
|
|
35
|
+
| undefined;
|
|
36
|
+
|
|
37
|
+
const raw = queryRoot?.[request.source.key];
|
|
38
|
+
if (raw == null) return null;
|
|
39
|
+
|
|
40
|
+
const nodes = (raw.edges ?? [])
|
|
41
|
+
.map((edge) => edge?.node)
|
|
42
|
+
.filter((node): node is unknown => node != null);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
nodes,
|
|
46
|
+
pageInfo: raw.pageInfo
|
|
47
|
+
? {
|
|
48
|
+
hasNextPage: raw.pageInfo.hasNextPage ?? false,
|
|
49
|
+
hasPreviousPage: raw.pageInfo.hasPreviousPage ?? false,
|
|
50
|
+
startCursor: raw.pageInfo.startCursor ?? null,
|
|
51
|
+
endCursor: raw.pageInfo.endCursor ?? null,
|
|
52
|
+
}
|
|
53
|
+
: null,
|
|
54
|
+
totalCount: raw.totalCount ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SObject fragment builder.
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `queryBuilder.ts`: `buildSelectionBody`,
|
|
5
|
+
* `renderField`, `buildSourceWhere`, `assertValidKey`, `KEY_PATTERN`, and the
|
|
6
|
+
* per-source variable/selection assembly. Emits one `QueryFragmentContribution`
|
|
7
|
+
* with `placement: "uiapi.query"` — the `${key}: ${objectName}(...) { … }`
|
|
8
|
+
* selection, its 4 per-source variable declarations, and their 4 values.
|
|
9
|
+
*
|
|
10
|
+
* Behaviour is byte-for-byte equivalent to the original inline logic; this is a
|
|
11
|
+
* move, not a rewrite.
|
|
12
|
+
*
|
|
13
|
+
* Selection sets are generated from `displayFields`:
|
|
14
|
+
* - string `"Name"` → `Name @optional { value displayValue }`
|
|
15
|
+
* - `{ name, raw: true }` → `Name`
|
|
16
|
+
* - `{ name, subfields }` → nested `Name @optional { ... }`
|
|
17
|
+
*
|
|
18
|
+
* The `idField` (default "Id") is always emitted; it is **not** wrapped
|
|
19
|
+
* because uiapi's Id is a scalar.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
buildFilter,
|
|
24
|
+
buildGlobalQueryClause,
|
|
25
|
+
type ActiveFilterValue,
|
|
26
|
+
} from "../../utils/filterUtils";
|
|
27
|
+
import { buildOrderBy } from "../../utils/sortUtils";
|
|
28
|
+
import type { DisplayField, SObjectSourceConfig } from "../../types";
|
|
29
|
+
import type { QueryFragmentContribution, SourceRequest } from "../types";
|
|
30
|
+
|
|
31
|
+
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
32
|
+
|
|
33
|
+
function assertValidKey(key: string): void {
|
|
34
|
+
if (!KEY_PATTERN.test(key)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Invalid source key "${key}". Must match /^[A-Za-z_][A-Za-z0-9_]*$/ to be a valid GraphQL alias and variable prefix.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Combines a per-source structured-filter clause with the global-q `or` clause.
|
|
43
|
+
* Returns `undefined` when neither side has any constraints.
|
|
44
|
+
*/
|
|
45
|
+
function buildSourceWhere(
|
|
46
|
+
source: SObjectSourceConfig,
|
|
47
|
+
q: string,
|
|
48
|
+
filters: ActiveFilterValue[],
|
|
49
|
+
): unknown {
|
|
50
|
+
const clauses: unknown[] = [];
|
|
51
|
+
const globalClause = buildGlobalQueryClause(q, source.searchableFields);
|
|
52
|
+
if (globalClause) clauses.push(globalClause);
|
|
53
|
+
const structured = buildFilter(filters, source.filterBy ?? []);
|
|
54
|
+
if (structured) clauses.push(structured);
|
|
55
|
+
if (clauses.length === 0) return undefined;
|
|
56
|
+
if (clauses.length === 1) return clauses[0];
|
|
57
|
+
return { and: clauses };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSelectionBody(source: SObjectSourceConfig): string {
|
|
61
|
+
const idField = source.idField ?? "Id";
|
|
62
|
+
const fieldLines = [` ${idField}`];
|
|
63
|
+
for (const field of source.displayFields) {
|
|
64
|
+
fieldLines.push(...renderField(field, " "));
|
|
65
|
+
}
|
|
66
|
+
return [
|
|
67
|
+
` edges { node {`,
|
|
68
|
+
...fieldLines,
|
|
69
|
+
` } }`,
|
|
70
|
+
` pageInfo { hasNextPage hasPreviousPage startCursor endCursor }`,
|
|
71
|
+
` totalCount`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderField(field: DisplayField, indentStr: string): string[] {
|
|
76
|
+
if (typeof field === "string") {
|
|
77
|
+
return [`${indentStr}${field} @optional { value displayValue }`];
|
|
78
|
+
}
|
|
79
|
+
if ("raw" in field) {
|
|
80
|
+
return [`${indentStr}${field.name}`];
|
|
81
|
+
}
|
|
82
|
+
const lines = [`${indentStr}${field.name} @optional {`];
|
|
83
|
+
for (const sub of field.subfields) {
|
|
84
|
+
lines.push(...renderField(sub, indentStr + " "));
|
|
85
|
+
}
|
|
86
|
+
lines.push(`${indentStr}}`);
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Builds one SObject source's `QueryFragmentContribution`.
|
|
92
|
+
*
|
|
93
|
+
* The fragment is the `${key}: ${objectName}(...) { … }` selection that lives
|
|
94
|
+
* inside `uiapi.query`; the 4 declarations/values are the per-source
|
|
95
|
+
* `first / after / where / orderBy` variables. Output matches the original
|
|
96
|
+
* inline builder exactly.
|
|
97
|
+
*/
|
|
98
|
+
export function buildSObjectFragment(
|
|
99
|
+
request: SourceRequest<SObjectSourceConfig>,
|
|
100
|
+
): QueryFragmentContribution {
|
|
101
|
+
const { source } = request;
|
|
102
|
+
assertValidKey(source.key);
|
|
103
|
+
|
|
104
|
+
const firstVar = `${source.key}_first`;
|
|
105
|
+
const afterVar = `${source.key}_after`;
|
|
106
|
+
const whereVar = `${source.key}_where`;
|
|
107
|
+
const orderByVar = `${source.key}_orderBy`;
|
|
108
|
+
const whereType = source.whereTypeName ?? `${source.objectName}_Filter`;
|
|
109
|
+
const orderByType = source.orderByTypeName ?? `${source.objectName}_OrderBy`;
|
|
110
|
+
|
|
111
|
+
const variableDeclarations = [
|
|
112
|
+
`$${firstVar}: Int`,
|
|
113
|
+
`$${afterVar}: String`,
|
|
114
|
+
`$${whereVar}: ${whereType}`,
|
|
115
|
+
`$${orderByVar}: ${orderByType}`,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const fragment =
|
|
119
|
+
`${source.key}: ${source.objectName}(` +
|
|
120
|
+
`first: $${firstVar}, ` +
|
|
121
|
+
`after: $${afterVar}, ` +
|
|
122
|
+
`where: $${whereVar}, ` +
|
|
123
|
+
`orderBy: $${orderByVar}` +
|
|
124
|
+
`) {\n` +
|
|
125
|
+
buildSelectionBody(source) +
|
|
126
|
+
`\n}`;
|
|
127
|
+
|
|
128
|
+
const variables: Record<string, unknown> = {
|
|
129
|
+
[firstVar]: request.pageSize,
|
|
130
|
+
[afterVar]: request.afterCursor ?? null,
|
|
131
|
+
[whereVar]: buildSourceWhere(source, request.q, request.filters) ?? null,
|
|
132
|
+
[orderByVar]: buildOrderBy(request.sort) ?? null,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
variableDeclarations,
|
|
137
|
+
variables,
|
|
138
|
+
placement: "uiapi.query",
|
|
139
|
+
fragment,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kind-agnostic adapter contracts for unified (multi-source) search.
|
|
3
|
+
*
|
|
4
|
+
* Every searchable backend ("sobject", "cms", …) is fronted by a
|
|
5
|
+
* {@link SourceAdapter}. The core query builder and result parser are
|
|
6
|
+
* completely kind-agnostic: they dispatch to the adapter registered for a
|
|
7
|
+
* source's `kind`, so a new backend plugs in by adding a `kind` + an adapter,
|
|
8
|
+
* never by touching the common builder/parser or any consumer.
|
|
9
|
+
*
|
|
10
|
+
* - {@link SourceAdapter.buildRequest} contributes a {@link QueryFragmentContribution}
|
|
11
|
+
* (its GraphQL fragment, variable declarations, and variable values) that the
|
|
12
|
+
* builder assembles into one combined document.
|
|
13
|
+
* - {@link SourceAdapter.parseResponse} receives the full response `data` root
|
|
14
|
+
* and extracts this source's `SourceResult`, returning `null` (never
|
|
15
|
+
* throwing) when its own subtree is absent or errored — this is what lets
|
|
16
|
+
* sources fail independently (partial-failure resilience).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SourceConfig, SourceResult } from "../types";
|
|
20
|
+
import type { ActiveFilterValue } from "../utils/filterUtils";
|
|
21
|
+
import type { SortState } from "../utils/sortUtils";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One source's contribution to the combined GraphQL document.
|
|
25
|
+
*
|
|
26
|
+
* The builder concatenates every source's `variableDeclarations` into the
|
|
27
|
+
* `query Search(...)` header and merges every source's `variables` into one
|
|
28
|
+
* flat map. Fragments are placed by `placement`:
|
|
29
|
+
* - `"uiapi.query"` → inside the shared `uiapi { query { … } }` block.
|
|
30
|
+
* - `"root"` → a sibling of `uiapi` at the document root (e.g. the CMS
|
|
31
|
+
* `managed_content { … }` block).
|
|
32
|
+
*
|
|
33
|
+
* A contribution MAY declare zero extra variables (e.g. the CMS bootstrap call
|
|
34
|
+
* omits `$cmsContentTypeFQNs` entirely). The builder handles declaration lists of
|
|
35
|
+
* differing lengths, so an unused/undeclared variable is never emitted.
|
|
36
|
+
*/
|
|
37
|
+
export interface QueryFragmentContribution {
|
|
38
|
+
/** Namespaced GraphQL variable declarations, e.g. `"$cmsKeyword: String!"`. */
|
|
39
|
+
variableDeclarations: string[];
|
|
40
|
+
/** Values for the declared variables, keyed by (unprefixed) variable name. */
|
|
41
|
+
variables: Record<string, unknown>;
|
|
42
|
+
/** Where the `fragment` is spliced into the combined document. */
|
|
43
|
+
placement: "uiapi.query" | "root";
|
|
44
|
+
/** The GraphQL selection fragment (unindented; the builder indents it). */
|
|
45
|
+
fragment: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A per-source search request. Generic over the source config so an adapter can
|
|
50
|
+
* narrow to its own config kind (e.g. `SourceRequest<CmsSourceConfig>`).
|
|
51
|
+
*
|
|
52
|
+
* The base fields (`source`/`q`/`filters`/`sort`/`pageSize`/`afterCursor`) are
|
|
53
|
+
* kind-agnostic. The trailing fields are optional and only populated for the
|
|
54
|
+
* kinds that need them; the sync query builder never resolves async values, so
|
|
55
|
+
* anything async (e.g. the CMS UIBundle id) is resolved by the caller and
|
|
56
|
+
* threaded in here.
|
|
57
|
+
*/
|
|
58
|
+
export interface SourceRequest<T extends SourceConfig = SourceConfig> {
|
|
59
|
+
/** The config for the source this request targets. */
|
|
60
|
+
source: T;
|
|
61
|
+
/** The global search term, broadcast to every source. */
|
|
62
|
+
q: string;
|
|
63
|
+
/** Active structured filters (SObject-relevant; other kinds may ignore). */
|
|
64
|
+
filters: ActiveFilterValue[];
|
|
65
|
+
/** Active sort (SObject-relevant; other kinds may ignore). */
|
|
66
|
+
sort: SortState | null;
|
|
67
|
+
/** Page size for this fetch (validated against `pageSizeOptions` upstream). */
|
|
68
|
+
pageSize: number;
|
|
69
|
+
/** Opaque forward cursor for this fetch (CMS decodes it to an offset). */
|
|
70
|
+
afterCursor: string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* CMS-only, optional. Present (non-null, non-empty) only when content types
|
|
73
|
+
* are known/selected; absent on the bootstrap call so the CMS adapter omits
|
|
74
|
+
* `$cmsContentTypeFQNs` entirely.
|
|
75
|
+
*/
|
|
76
|
+
contentTypes?: string[] | null;
|
|
77
|
+
/**
|
|
78
|
+
* CMS-only, optional. The resolved UIBundle id fed into the CMS search
|
|
79
|
+
* query. Resolved by the caller (async `getUIBundleId()`) BEFORE the sync
|
|
80
|
+
* query builder runs, then threaded in here so `buildRequest` stays
|
|
81
|
+
* synchronous. Ignored by non-CMS adapters.
|
|
82
|
+
*/
|
|
83
|
+
uiBundleId?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Runtime adapter for one source `kind`. Bridges a kind-agnostic
|
|
88
|
+
* {@link SourceRequest} to a GraphQL fragment and back to a {@link SourceResult}.
|
|
89
|
+
*/
|
|
90
|
+
export interface SourceAdapter<T extends SourceConfig = SourceConfig> {
|
|
91
|
+
/** The `kind` discriminator this adapter handles. */
|
|
92
|
+
readonly kind: T["kind"];
|
|
93
|
+
/** Build this source's fragment/variables contribution for the combined doc. */
|
|
94
|
+
buildRequest(request: SourceRequest<T>): QueryFragmentContribution;
|
|
95
|
+
/**
|
|
96
|
+
* Extract this source's result from the full response `data` root. Returns
|
|
97
|
+
* `null` when the source's own subtree is absent or errored — never throws
|
|
98
|
+
* for a partial failure.
|
|
99
|
+
*/
|
|
100
|
+
parseResponse(root: Record<string, unknown>, request: SourceRequest<T>): SourceResult | null;
|
|
101
|
+
}
|