@salesforce/ui-bundle-template-app-react-sample-b2e 11.55.1 → 11.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +8 -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,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
|
+
};
|