@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.
Files changed (52) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/README.md +235 -0
  4. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/__tests__/queryBuilder.test.ts +166 -0
  5. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/channelResolver.test.ts +73 -0
  6. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +127 -0
  7. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeSessionCache.test.ts +114 -0
  8. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeUtils.test.ts +82 -0
  9. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/orgApiVersionService.test.ts +57 -0
  10. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/parseResponse.test.ts +102 -0
  11. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +98 -0
  12. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchableContentTypesService.test.ts +153 -0
  13. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
  14. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
  15. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  16. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
  17. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
  18. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  19. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
  20. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  21. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +54 -0
  22. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
  23. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
  24. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/types.ts +66 -0
  25. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/registry.ts +38 -0
  26. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/index.ts +19 -0
  27. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
  28. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
  29. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +101 -0
  30. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/__tests__/searchService.test.ts +304 -0
  31. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +110 -45
  32. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
  33. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +34 -17
  34. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +31 -13
  35. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +17 -11
  36. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +9 -4
  37. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/Search.test.tsx +173 -0
  38. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
  39. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
  40. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/CmsResultRow.test.tsx +139 -0
  41. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/resolveResultRenderer.test.ts +104 -0
  42. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
  43. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +7 -2
  44. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/constants.ts +8 -0
  45. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +313 -50
  46. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +26 -1
  47. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +62 -118
  48. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +74 -5
  49. package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
  50. package/dist/package-lock.json +2 -2
  51. package/dist/package.json +1 -1
  52. package/package.json +2 -2
@@ -0,0 +1,26 @@
1
+ /** Service to gate CMS search on the bundle's build-time API version. */
2
+
3
+ import { getBuildApiVersion } from "./apiUtils";
4
+
5
+ /** Minimum API version that supports CMS search. */
6
+ export const MIN_CMS_API_VERSION = 68;
7
+
8
+ /**
9
+ * Resolves whether CMS search can run. Gated on the bundle's build-time API
10
+ * version (`__SF_API_VERSION__`, injected from the resolved org's API version
11
+ * at build time) being >= {@link MIN_CMS_API_VERSION}.
12
+ *
13
+ * The build version is the version every SDK GraphQL/REST request is actually
14
+ * issued at, so it already reflects a concrete, org-supported API version: a
15
+ * bundle built against an org resolves that org's max version, and a bundle
16
+ * built below v68 sends its CMS query to `/services/data/v67.0/graphql` where
17
+ * the v68 CMS backend is unavailable. Gating on the build version alone is
18
+ * therefore both necessary and sufficient — and, unlike a runtime probe of the
19
+ * bare `/services/data/` version-listing endpoint (which is not routed on guest
20
+ * Experience Sites and would fail-close there), it works on every surface.
21
+ *
22
+ * Async to preserve the call-site contract; resolves synchronously.
23
+ */
24
+ export function getOrgSupportsCmsSearch(): Promise<boolean> {
25
+ return Promise.resolve(getBuildApiVersion() >= MIN_CMS_API_VERSION);
26
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Connect REST caller for a CMS channel's searchable content types:
3
+ * GET /connect/cms/channels/{channelId}/searchable-content-types?page=N&pageSize=25
4
+ * Returns the validated content types (FQN + server label) that populate the
5
+ * content-type scope entries.
6
+ */
7
+
8
+ import { connectGetJson, connectUrl, safeEncodePath } from "./apiUtils";
9
+ import {
10
+ isValidCmsFqn,
11
+ formatContentTypeLabel,
12
+ type DiscoveredContentType,
13
+ } from "../contentTypeUtils";
14
+
15
+ /** Hard-coded REST page size. NEVER user input. */
16
+ const REST_PAGE_SIZE = 25;
17
+
18
+ /** Safety cap so a misbehaving backend cannot spin an unbounded loop. */
19
+ const MAX_PAGES = 100;
20
+
21
+ /** Channel id shape guard — `0ap` + 12–15 alphanumerics — validates the discovery channel id before the searchable-content-types REST call. */
22
+ export const CHANNEL_ID_PATTERN = /^0ap[a-zA-Z0-9]{12,15}$/;
23
+
24
+ /** One raw entry from the Connect response. */
25
+ interface RawContentTypeEntry {
26
+ /** FQN with the `sfdc_cms__` prefix (e.g. "sfdc_cms__news"). */
27
+ id?: string;
28
+ /** Server-provided display name (e.g. "News"). */
29
+ label?: string;
30
+ /** Bare developer name without the prefix (e.g. "news"). */
31
+ name?: string;
32
+ // Legacy/alternate FQN field names kept as fallbacks.
33
+ contentType?: string;
34
+ fqn?: string;
35
+ developerName?: string;
36
+ isSearchable?: boolean;
37
+ searchable?: boolean;
38
+ }
39
+
40
+ /** Loose shape of one page of the Connect response. */
41
+ interface RawContentTypesPage {
42
+ items?: RawContentTypeEntry[];
43
+ contentTypes?: RawContentTypeEntry[];
44
+ }
45
+
46
+ /** Reads the entries array from a page payload (array or object form). */
47
+ function pageItems(payload: unknown): RawContentTypeEntry[] {
48
+ if (Array.isArray(payload)) return payload as RawContentTypeEntry[];
49
+ const obj = payload as RawContentTypesPage | null;
50
+ if (obj?.items && Array.isArray(obj.items)) return obj.items;
51
+ if (obj?.contentTypes && Array.isArray(obj.contentTypes)) return obj.contentTypes;
52
+ return [];
53
+ }
54
+
55
+ /** True when the entry is not explicitly marked non-searchable. */
56
+ function isSearchable(entry: RawContentTypeEntry): boolean {
57
+ if (typeof entry.isSearchable === "boolean") return entry.isSearchable;
58
+ if (typeof entry.searchable === "boolean") return entry.searchable;
59
+ // No flag present → do not exclude on searchability (FQN validity still gates).
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * Extracts the prefixed FQN from an entry. `id` is the current field; the
65
+ * others are fallbacks for alternate/legacy payloads. `name` is intentionally
66
+ * NOT read here — it is the bare (unprefixed) developer name and would fail
67
+ * `isValidCmsFqn`.
68
+ */
69
+ function entryFqn(entry: RawContentTypeEntry): string | undefined {
70
+ return entry.id ?? entry.contentType ?? entry.fqn ?? entry.developerName;
71
+ }
72
+
73
+ /**
74
+ * Fetches every searchable content type for `channelId`, paginating the
75
+ * Connect endpoint until a short page is returned, then filtering to searchable
76
+ * entries whose FQN passes `isValidCmsFqn`. Each result carries the FQN and the
77
+ * server-provided display `label` (falling back to a derived label only when
78
+ * the response omits one). De-duplicates by FQN, preserving first-seen order.
79
+ *
80
+ * @throws when `channelId` fails the shape guard (before any network call).
81
+ */
82
+ export async function fetchSearchableContentTypes(
83
+ channelId: string,
84
+ ): Promise<DiscoveredContentType[]> {
85
+ if (!CHANNEL_ID_PATTERN.test(channelId)) {
86
+ throw new Error(`Invalid CMS channel id "${channelId}".`);
87
+ }
88
+
89
+ const encoded = safeEncodePath(channelId);
90
+ const collected: DiscoveredContentType[] = [];
91
+ const seen = new Set<string>();
92
+
93
+ for (let page = 0; page < MAX_PAGES; page++) {
94
+ const url = connectUrl(
95
+ `/cms/channels/${encoded}/searchable-content-types?page=${page}&pageSize=${REST_PAGE_SIZE}`,
96
+ );
97
+ const payload = await connectGetJson(url);
98
+ const items = pageItems(payload);
99
+
100
+ for (const entry of items) {
101
+ const fqn = entryFqn(entry);
102
+ if (!fqn || !isSearchable(entry) || !isValidCmsFqn(fqn) || seen.has(fqn)) {
103
+ continue;
104
+ }
105
+ seen.add(fqn);
106
+ // Prefer the server label; fall back to a label derived from the FQN.
107
+ const label = entry.label?.trim() || formatContentTypeLabel(fqn);
108
+ collected.push({ fqn, label });
109
+ }
110
+
111
+ // Last page reached when fewer than a full page came back.
112
+ if (items.length < REST_PAGE_SIZE) break;
113
+ }
114
+
115
+ return collected;
116
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Extracts the CMS channel id from a search result so it can feed the
3
+ * searchable-content-types Connect endpoint. There is one CMS channel for the
4
+ * app, so any result carries it:
5
+ *
6
+ * nodes[0].managedContentChannelDeliveryDetails[0].managedContentChannelDetails.id
7
+ *
8
+ * Returns `null` when any hop is missing (empty nodes, missing delivery
9
+ * details, missing channel details / id) — the caller simply skips discovery.
10
+ */
11
+
12
+ import type { SourceResult } from "../../types";
13
+ import type { CmsSearchResponse, CmsSearchItem } from "./types";
14
+
15
+ /** Accepts either a normalised `SourceResult` or a raw `CmsSearchResponse`. */
16
+ type ChannelSource = SourceResult | CmsSearchResponse | null | undefined;
17
+
18
+ function getNodes(result: ChannelSource): unknown[] {
19
+ if (!result) return [];
20
+ // SourceResult carries `nodes`; CmsSearchResponse carries `items`.
21
+ if ("nodes" in result && Array.isArray(result.nodes)) return result.nodes;
22
+ if ("items" in result && Array.isArray(result.items)) return result.items;
23
+ return [];
24
+ }
25
+
26
+ /**
27
+ * Returns the channel id derived from the first result's delivery details, or
28
+ * `null` when any level is absent. Validate the returned id's shape before it
29
+ * enters a REST path (the discovery service does this).
30
+ */
31
+ export function resolveChannelId(result: ChannelSource): string | null {
32
+ const nodes = getNodes(result);
33
+ const first = nodes[0] as Partial<CmsSearchItem> | undefined;
34
+ if (!first) return null;
35
+
36
+ const delivery = first.managedContentChannelDeliveryDetails?.[0];
37
+ const id = delivery?.managedContentChannelDetails?.id;
38
+
39
+ return typeof id === "string" && id.length > 0 ? id : null;
40
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Builds the CMS `managed_content { search { searchContentInChannels … } }`
3
+ * fragment as a {@link QueryFragmentContribution} with `placement: "root"` — a
4
+ * sibling of the `uiapi` block in the combined document.
5
+ *
6
+ * The `$cmsContentTypeFQNs` declaration, its `contentTypeFQNs:` argument, and
7
+ * its variable value are all emitted only when a non-empty content-type list is
8
+ * passed; on the bootstrap call (no types known) they are omitted entirely.
9
+ */
10
+
11
+ import type { QueryFragmentContribution } from "../types";
12
+
13
+ export interface BuildCmsFragmentParams {
14
+ /** Search keyword → `$cmsKeyword`. */
15
+ keyword: string;
16
+ /** UIBundle Id fed into the query → `$UIBundleId` (from `getUIBundleId()`). */
17
+ uiBundleId: string;
18
+ /** Pagination offset → `$cmsOffset` (already clamped `>= 0` by the caller). */
19
+ offset: number;
20
+ /** Page size → `$cmsLimit` (validated against `pageSizeOptions` upstream). */
21
+ limit: number;
22
+ /**
23
+ * Discovered/selected content-type FQNs → the schema's `contentTypeFQNs`
24
+ * argument (via `$cmsContentTypeFQNs`). When absent, null, or empty, those
25
+ * pieces are omitted entirely (bootstrap call). Callers pass ONLY
26
+ * `isValidCmsFqn`-validated values.
27
+ */
28
+ contentTypes?: string[] | null;
29
+ }
30
+
31
+ /**
32
+ * Builds the CMS fragment contribution. The content-type filter pieces are
33
+ * included only when `params.contentTypes` is a non-empty array.
34
+ */
35
+ export function buildCmsQueryFragment(params: BuildCmsFragmentParams): QueryFragmentContribution {
36
+ const { keyword, uiBundleId, offset, limit, contentTypes } = params;
37
+
38
+ const hasContentTypes = Array.isArray(contentTypes) && contentTypes.length > 0;
39
+
40
+ const variableDeclarations = [
41
+ "$cmsKeyword: String!",
42
+ "$UIBundleId: ID!",
43
+ "$cmsOffset: Int!",
44
+ "$cmsLimit: Int!",
45
+ ];
46
+
47
+ const variables: Record<string, unknown> = {
48
+ cmsKeyword: keyword,
49
+ UIBundleId: uiBundleId,
50
+ cmsOffset: offset,
51
+ cmsLimit: limit,
52
+ };
53
+
54
+ // CONDITIONAL: declare + pass + supply the content-type filter only when a
55
+ // non-empty list is provided. Otherwise these three pieces are all absent.
56
+ // The schema argument is `contentTypeFQNs` (a list of content-type FQNs) —
57
+ // NOT `contentTypes`; the latter is rejected as an unknown field argument.
58
+ const contentTypesArg = hasContentTypes ? "\n contentTypeFQNs: $cmsContentTypeFQNs" : "";
59
+ if (hasContentTypes) {
60
+ variableDeclarations.push("$cmsContentTypeFQNs: [String!]");
61
+ variables.cmsContentTypeFQNs = contentTypes;
62
+ }
63
+
64
+ const fragment = `managed_content {
65
+ search {
66
+ searchContentInChannels(
67
+ searchIdentifiers: { uibundleIds: [$UIBundleId] }
68
+ keyword: $cmsKeyword
69
+ pagination: { limit: $cmsLimit, offset: $cmsOffset }${contentTypesArg}
70
+ ) {
71
+ total
72
+ offset
73
+ pageSize
74
+ items {
75
+ managedContentId
76
+ managedContentKey
77
+ title
78
+ contentType
79
+ language
80
+ highlightedSnippet
81
+ managedContentChannelDeliveryDetails {
82
+ contentUrl
83
+ publishedDate
84
+ managedContentChannelDetails { id name type }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }`;
90
+
91
+ return {
92
+ variableDeclarations,
93
+ variables,
94
+ placement: "root",
95
+ fragment,
96
+ };
97
+ }
@@ -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
+ };