@salesforce/ui-bundle-template-app-react-template-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.
- package/dist/CHANGELOG.md +8 -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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessionStorage`-backed persistence for discovered CMS content types.
|
|
3
|
+
*
|
|
4
|
+
* Discovery is otherwise ephemeral: the in-memory module cache in
|
|
5
|
+
* {@link useSearchableContentTypes} is wiped on a hard reload, and content
|
|
6
|
+
* types are only re-derived once a CMS search actually returns a channel id.
|
|
7
|
+
* Persisting them per channel lets the scope dropdown keep showing the
|
|
8
|
+
* discovered types across any number of reloads, and lets the discovery hook
|
|
9
|
+
* rehydrate them synchronously (no re-fetch) on mount.
|
|
10
|
+
*
|
|
11
|
+
* Two things are persisted:
|
|
12
|
+
* - `types:<channelId>` — the validated `{ fqn, label }[]` for that
|
|
13
|
+
* channel. Keyed by the channel id, which is channel-specific already.
|
|
14
|
+
* - `last-channel-id:<uiBundleId>` — the most recently discovered channel id
|
|
15
|
+
* FOR THAT UIBundle, so the hook can seed its discovery state on mount
|
|
16
|
+
* before any CMS result arrives. Keyed by UIBundle id (not origin-wide):
|
|
17
|
+
* content types are channel-specific, so a second UIBundle opened in the
|
|
18
|
+
* same session must NOT inherit the first bundle's channel/types.
|
|
19
|
+
*
|
|
20
|
+
* Every access is wrapped: `sessionStorage` can be absent (SSR), throw
|
|
21
|
+
* (private-mode / disabled cookies), or hold corrupt JSON. On any failure we
|
|
22
|
+
* degrade to "no cache" rather than surfacing an error — persistence is an
|
|
23
|
+
* enhancement, never a hard dependency.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { isValidCmsFqn, type DiscoveredContentType } from "./contentTypeUtils";
|
|
27
|
+
import { CHANNEL_ID_PATTERN } from "./api/searchableContentTypesService";
|
|
28
|
+
import { UI_BUNDLE_ID_PATTERN } from "./searchChannel";
|
|
29
|
+
|
|
30
|
+
const KEY_PREFIX = "cms-search:content-types:";
|
|
31
|
+
const TYPES_KEY = (channelId: string) => `${KEY_PREFIX}types:${channelId}`;
|
|
32
|
+
const LAST_CHANNEL_KEY = (uiBundleId: string) => `${KEY_PREFIX}last-channel-id:${uiBundleId}`;
|
|
33
|
+
|
|
34
|
+
/** Returns the `sessionStorage` object, or `null` when it is unavailable/throws. */
|
|
35
|
+
function storage(): Storage | null {
|
|
36
|
+
try {
|
|
37
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
38
|
+
return sessionStorage;
|
|
39
|
+
} catch {
|
|
40
|
+
// Accessing `sessionStorage` can throw when storage is disabled.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Narrows an unknown parsed value to a clean `DiscoveredContentType[]`. */
|
|
46
|
+
function sanitize(parsed: unknown): DiscoveredContentType[] {
|
|
47
|
+
if (!Array.isArray(parsed)) return [];
|
|
48
|
+
const out: DiscoveredContentType[] = [];
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
for (const entry of parsed) {
|
|
51
|
+
if (!entry || typeof entry !== "object") continue;
|
|
52
|
+
const { fqn, label } = entry as { fqn?: unknown; label?: unknown };
|
|
53
|
+
// Re-validate the FQN on read: cached data is untrusted input and its
|
|
54
|
+
// values flow straight into the query variable and rendered labels.
|
|
55
|
+
if (typeof fqn !== "string" || !isValidCmsFqn(fqn) || seen.has(fqn)) continue;
|
|
56
|
+
if (typeof label !== "string" || label.length === 0) continue;
|
|
57
|
+
seen.add(fqn);
|
|
58
|
+
out.push({ fqn, label });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads the persisted content types for `channelId`. Returns `null` when there
|
|
65
|
+
* is no (valid) cache entry, so callers can distinguish "never persisted" from
|
|
66
|
+
* "persisted as empty".
|
|
67
|
+
*/
|
|
68
|
+
export function readCachedContentTypes(channelId: string): DiscoveredContentType[] | null {
|
|
69
|
+
const store = storage();
|
|
70
|
+
if (!store || !CHANNEL_ID_PATTERN.test(channelId)) return null;
|
|
71
|
+
let raw: string | null;
|
|
72
|
+
try {
|
|
73
|
+
raw = store.getItem(TYPES_KEY(channelId));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (raw == null) return null;
|
|
78
|
+
try {
|
|
79
|
+
return sanitize(JSON.parse(raw));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Persists `types` for `channelId`. No-ops (never throws) when storage is
|
|
87
|
+
* unavailable or the id is malformed. The last-discovered-channel pointer is
|
|
88
|
+
* written separately via {@link writeLastChannelId}, which needs the UIBundle
|
|
89
|
+
* id this channel belongs to.
|
|
90
|
+
*/
|
|
91
|
+
export function writeCachedContentTypes(channelId: string, types: DiscoveredContentType[]): void {
|
|
92
|
+
const store = storage();
|
|
93
|
+
if (!store || !CHANNEL_ID_PATTERN.test(channelId)) return;
|
|
94
|
+
try {
|
|
95
|
+
store.setItem(TYPES_KEY(channelId), JSON.stringify(types));
|
|
96
|
+
} catch {
|
|
97
|
+
// Quota exceeded / disabled storage — persistence is best-effort.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Records `channelId` as the last discovered channel FOR `uiBundleId`. Keyed by
|
|
103
|
+
* UIBundle id so a different bundle opened in the same session never inherits
|
|
104
|
+
* this channel. No-ops (never throws) when storage is unavailable or either id
|
|
105
|
+
* is malformed.
|
|
106
|
+
*/
|
|
107
|
+
export function writeLastChannelId(uiBundleId: string, channelId: string): void {
|
|
108
|
+
const store = storage();
|
|
109
|
+
if (!store || !UI_BUNDLE_ID_PATTERN.test(uiBundleId) || !CHANNEL_ID_PATTERN.test(channelId)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
store.setItem(LAST_CHANNEL_KEY(uiBundleId), channelId);
|
|
114
|
+
} catch {
|
|
115
|
+
// Quota exceeded / disabled storage — persistence is best-effort.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns the last discovered channel id for `uiBundleId` (validated shape), or
|
|
121
|
+
* `null`. Lets the discovery hook seed its channel on mount so THIS bundle's
|
|
122
|
+
* cached types render immediately, before any CMS search result comes back.
|
|
123
|
+
*/
|
|
124
|
+
export function readLastChannelId(uiBundleId: string): string | null {
|
|
125
|
+
const store = storage();
|
|
126
|
+
if (!store || !UI_BUNDLE_ID_PATTERN.test(uiBundleId)) return null;
|
|
127
|
+
let raw: string | null;
|
|
128
|
+
try {
|
|
129
|
+
raw = store.getItem(LAST_CHANNEL_KEY(uiBundleId));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return raw != null && CHANNEL_ID_PATTERN.test(raw) ? raw : null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS content-type FQN helpers.
|
|
3
|
+
*
|
|
4
|
+
* `isValidCmsFqn` / `CMS_FQN_PATTERN` guard every FQN before it enters query
|
|
5
|
+
* state, the GraphQL variable, or a rendered label. `formatContentTypeLabel`
|
|
6
|
+
* turns an FQN into a human-readable dropdown/section label.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Formats a CMS content type FQN into a human-readable label.
|
|
11
|
+
* Strips "sfdc_cms__" prefix, splits camelCase, title-cases each word.
|
|
12
|
+
*
|
|
13
|
+
* Examples:
|
|
14
|
+
* "sfdc_cms__blogPost" → "Blog Post"
|
|
15
|
+
* "sfdc_cms__news" → "News"
|
|
16
|
+
* "sfdc_cms__document" → "Document"
|
|
17
|
+
* "sfdc_cms__pressRelease" → "Press Release"
|
|
18
|
+
*/
|
|
19
|
+
export function formatContentTypeLabel(fqn: string): string {
|
|
20
|
+
const stripped = fqn.replace(/^sfdc_cms__/, "");
|
|
21
|
+
const words = stripped.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
22
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CMS_FQN_PATTERN = /^sfdc_cms__[a-zA-Z][a-zA-Z0-9]{0,39}$/;
|
|
26
|
+
|
|
27
|
+
export function isValidCmsFqn(fqn: string): boolean {
|
|
28
|
+
return CMS_FQN_PATTERN.test(fqn);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A discovered CMS content type: its FQN (`sfdc_cms__…`, used as the scope value
|
|
33
|
+
* and query filter) and its server-provided display `label` (e.g. "News").
|
|
34
|
+
*/
|
|
35
|
+
export interface DiscoveredContentType {
|
|
36
|
+
fqn: string;
|
|
37
|
+
label: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-cache hook for a CMS channel's searchable content types.
|
|
3
|
+
*
|
|
4
|
+
* A module-level `Map` cache + a `Map` of in-flight promises, keyed by
|
|
5
|
+
* `channelId`, so the Connect endpoint is hit once per channel per session and
|
|
6
|
+
* concurrent callers share one request. A `null` channelId short-circuits (no
|
|
7
|
+
* fetch) — discovery only runs after a CMS result yields a channel id.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useRef } from "react";
|
|
11
|
+
import { fetchSearchableContentTypes } from "../api/searchableContentTypesService";
|
|
12
|
+
import type { DiscoveredContentType } from "../contentTypeUtils";
|
|
13
|
+
import { readCachedContentTypes, writeCachedContentTypes } from "../contentTypeSessionCache";
|
|
14
|
+
|
|
15
|
+
/** Session cache: channelId → discovered content types. Populated once, reused after. */
|
|
16
|
+
const cache = new Map<string, DiscoveredContentType[]>();
|
|
17
|
+
/** In-flight dedupe: channelId → the pending fetch promise. */
|
|
18
|
+
const inflight = new Map<string, Promise<DiscoveredContentType[]>>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns any already-known content types for `channelId` WITHOUT fetching:
|
|
22
|
+
* the in-memory module cache first, then the `sessionStorage` cache (which
|
|
23
|
+
* survives a hard reload). The `sessionStorage` hit is promoted into the module
|
|
24
|
+
* cache so subsequent lookups stay in memory. Returns `null` when neither holds
|
|
25
|
+
* the channel, so callers know a network fetch is still required.
|
|
26
|
+
*/
|
|
27
|
+
function peekCachedContentTypes(channelId: string): DiscoveredContentType[] | null {
|
|
28
|
+
const inMemory = cache.get(channelId);
|
|
29
|
+
if (inMemory) return inMemory;
|
|
30
|
+
const persisted = readCachedContentTypes(channelId);
|
|
31
|
+
if (persisted) {
|
|
32
|
+
cache.set(channelId, persisted);
|
|
33
|
+
return persisted;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetches (once per session) and caches the searchable content types for a
|
|
40
|
+
* channel. Concurrent callers for the same channel share one request. Results
|
|
41
|
+
* are cached in memory AND persisted to `sessionStorage` so a hard reload can
|
|
42
|
+
* rehydrate them without a re-fetch.
|
|
43
|
+
*/
|
|
44
|
+
export function getSearchableContentTypes(channelId: string): Promise<DiscoveredContentType[]> {
|
|
45
|
+
const cached = peekCachedContentTypes(channelId);
|
|
46
|
+
if (cached) return Promise.resolve(cached);
|
|
47
|
+
|
|
48
|
+
const pending = inflight.get(channelId);
|
|
49
|
+
if (pending) return pending;
|
|
50
|
+
|
|
51
|
+
const promise = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await fetchSearchableContentTypes(channelId);
|
|
54
|
+
cache.set(channelId, result);
|
|
55
|
+
// Persist so the scope dropdown keeps the types across hard reloads.
|
|
56
|
+
writeCachedContentTypes(channelId, result);
|
|
57
|
+
return result;
|
|
58
|
+
} finally {
|
|
59
|
+
inflight.delete(channelId);
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
inflight.set(channelId, promise);
|
|
63
|
+
return promise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UseSearchableContentTypesResult {
|
|
67
|
+
/** Discovered, validated content types (FQN + label; empty until resolved). */
|
|
68
|
+
contentTypes: DiscoveredContentType[];
|
|
69
|
+
loading: boolean;
|
|
70
|
+
error: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* React hook wrapping {@link getSearchableContentTypes}. Re-runs only when the
|
|
75
|
+
* `channelId` string changes; a `null` id yields an empty, non-loading state.
|
|
76
|
+
*/
|
|
77
|
+
export function useSearchableContentTypes(
|
|
78
|
+
channelId: string | null,
|
|
79
|
+
): UseSearchableContentTypesResult {
|
|
80
|
+
// Seed synchronously from the in-memory OR sessionStorage cache so a hard
|
|
81
|
+
// reload shows the persisted content types on the first render (before any
|
|
82
|
+
// CMS search result comes back), with no fetch and no loading flash.
|
|
83
|
+
const [state, setState] = useState<UseSearchableContentTypesResult>(() => {
|
|
84
|
+
const seeded = channelId ? peekCachedContentTypes(channelId) : null;
|
|
85
|
+
return {
|
|
86
|
+
contentTypes: seeded ?? [],
|
|
87
|
+
loading: channelId != null && seeded == null,
|
|
88
|
+
error: null,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const isCancelled = useRef(false);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
isCancelled.current = false;
|
|
95
|
+
|
|
96
|
+
if (channelId == null) {
|
|
97
|
+
setState({ contentTypes: [], loading: false, error: null });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cached = peekCachedContentTypes(channelId);
|
|
102
|
+
if (cached) {
|
|
103
|
+
setState({ contentTypes: cached, loading: false, error: null });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setState((s) => ({ ...s, loading: true, error: null }));
|
|
108
|
+
getSearchableContentTypes(channelId)
|
|
109
|
+
.then((contentTypes) => {
|
|
110
|
+
if (isCancelled.current) return;
|
|
111
|
+
setState({ contentTypes, loading: false, error: null });
|
|
112
|
+
})
|
|
113
|
+
.catch((err) => {
|
|
114
|
+
if (isCancelled.current) return;
|
|
115
|
+
setState({
|
|
116
|
+
contentTypes: [],
|
|
117
|
+
loading: false,
|
|
118
|
+
error: err instanceof Error ? err.message : String(err),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
isCancelled.current = true;
|
|
124
|
+
};
|
|
125
|
+
}, [channelId]);
|
|
126
|
+
|
|
127
|
+
return state;
|
|
128
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS adapter — fronts the `managed_content` GraphQL block behind the
|
|
3
|
+
* kind-agnostic {@link SourceAdapter} contract.
|
|
4
|
+
*
|
|
5
|
+
* `buildRequest` is SYNCHRONOUS (the query builder is sync): it reads the
|
|
6
|
+
* already-resolved `request.uiBundleId` (the caller resolves the async
|
|
7
|
+
* `getUIBundleId()` upstream and threads it in), decodes `afterCursor` → offset,
|
|
8
|
+
* and passes `request.contentTypes` through to the fragment builder ONLY when
|
|
9
|
+
* non-empty (bootstrap omits them). `parseResponse` delegates to the CMS parser.
|
|
10
|
+
*
|
|
11
|
+
* The registry (`../registry.ts`) imports `cmsAdapter` from here by name.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CmsSourceConfig } from "./types";
|
|
15
|
+
import type { SourceAdapter, SourceRequest, QueryFragmentContribution } from "../types";
|
|
16
|
+
import { buildCmsQueryFragment } from "./cmsQueryFragment";
|
|
17
|
+
import { parseCmsResponse } from "./parseResponse";
|
|
18
|
+
import { isValidCmsFqn } from "./contentTypeUtils";
|
|
19
|
+
|
|
20
|
+
/** Decodes an opaque forward cursor into a non-negative integer offset. */
|
|
21
|
+
function decodeOffset(afterCursor: string | undefined): number {
|
|
22
|
+
if (afterCursor == null) return 0;
|
|
23
|
+
const parsed = parseInt(afterCursor, 10);
|
|
24
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds the CMS fragment contribution from a request. Content types are passed
|
|
30
|
+
* through only when non-empty AND every entry is a valid FQN (defence in depth —
|
|
31
|
+
* `useSearch` already validates before state).
|
|
32
|
+
*/
|
|
33
|
+
function buildCmsRequest(request: SourceRequest<CmsSourceConfig>): QueryFragmentContribution {
|
|
34
|
+
const offset = decodeOffset(request.afterCursor);
|
|
35
|
+
|
|
36
|
+
const contentTypes =
|
|
37
|
+
Array.isArray(request.contentTypes) && request.contentTypes.length > 0
|
|
38
|
+
? request.contentTypes.filter(isValidCmsFqn)
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
return buildCmsQueryFragment({
|
|
42
|
+
keyword: request.q,
|
|
43
|
+
uiBundleId: request.uiBundleId ?? "",
|
|
44
|
+
offset,
|
|
45
|
+
limit: request.pageSize,
|
|
46
|
+
contentTypes: contentTypes && contentTypes.length > 0 ? contentTypes : null,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const cmsAdapter: SourceAdapter<CmsSourceConfig> = {
|
|
51
|
+
kind: "cms",
|
|
52
|
+
buildRequest: buildCmsRequest,
|
|
53
|
+
parseResponse: parseCmsResponse,
|
|
54
|
+
};
|
|
@@ -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
|
+
}
|