@salesforce/ui-bundle-template-app-react-sample-b2e 11.55.0 → 11.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/README.md +235 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/__tests__/queryBuilder.test.ts +166 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/channelResolver.test.ts +73 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +127 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeSessionCache.test.ts +114 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/contentTypeUtils.test.ts +82 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/orgApiVersionService.test.ts +57 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/parseResponse.test.ts +102 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +98 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchableContentTypesService.test.ts +153 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +54 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/types.ts +66 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/registry.ts +38 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/index.ts +19 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +101 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/__tests__/searchService.test.ts +304 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/api/searchService.ts +110 -45
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +34 -17
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/Search.tsx +31 -13
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +17 -11
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +9 -4
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/Search.test.tsx +173 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/CmsResultRow.test.tsx +139 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/__tests__/resolveResultRenderer.test.ts +104 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/config.json +7 -2
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/constants.ts +8 -0
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +313 -50
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/index.ts +26 -1
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/queryBuilder.ts +62 -118
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/types.ts +74 -5
- package/dist/force-app/main/default/uiBundles/propertymanagementapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tests for the sessionStorage-backed content-type cache: per-channel type
|
|
9
|
+
* persistence with FQN re-validation, and the per-UIBundle last-channel pointer
|
|
10
|
+
* (which must NOT be shared origin-wide, so a second bundle in the same session
|
|
11
|
+
* never inherits the first bundle's channel). Also covers the degrade-to-null
|
|
12
|
+
* behavior on corrupt JSON and malformed ids.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
// The module transitively imports `searchChannel.ts` (for UI_BUNDLE_ID_PATTERN),
|
|
17
|
+
// which pulls in the platform SDK. Stub it so vite doesn't resolve SDK internals
|
|
18
|
+
// under jsdom — this test never calls into the SDK.
|
|
19
|
+
vi.mock("@salesforce/platform-sdk", () => ({
|
|
20
|
+
getCurrentApp: vi.fn(() => ({ identity: { bundleId: "" } })),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
readCachedContentTypes,
|
|
25
|
+
writeCachedContentTypes,
|
|
26
|
+
readLastChannelId,
|
|
27
|
+
writeLastChannelId,
|
|
28
|
+
} from "../contentTypeSessionCache";
|
|
29
|
+
import type { DiscoveredContentType } from "../contentTypeUtils";
|
|
30
|
+
|
|
31
|
+
const CHANNEL_A = "0ap000000000001";
|
|
32
|
+
const CHANNEL_B = "0ap000000000002";
|
|
33
|
+
const BUNDLE_A = "9YE000000000001";
|
|
34
|
+
const BUNDLE_B = "9YE000000000002";
|
|
35
|
+
|
|
36
|
+
const TYPES: DiscoveredContentType[] = [
|
|
37
|
+
{ fqn: "sfdc_cms__news", label: "News" },
|
|
38
|
+
{ fqn: "sfdc_cms__blog", label: "Blog" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe("contentTypeSessionCache", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
sessionStorage.clear();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("content types (keyed by channel)", () => {
|
|
47
|
+
it("round-trips validated types for a channel", () => {
|
|
48
|
+
writeCachedContentTypes(CHANNEL_A, TYPES);
|
|
49
|
+
expect(readCachedContentTypes(CHANNEL_A)).toEqual(TYPES);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns null for a channel that was never written", () => {
|
|
53
|
+
expect(readCachedContentTypes(CHANNEL_A)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not write under a malformed channel id", () => {
|
|
57
|
+
writeCachedContentTypes("not-a-channel", TYPES);
|
|
58
|
+
expect(readCachedContentTypes("not-a-channel")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("drops entries with an invalid FQN on read (untrusted cache)", () => {
|
|
62
|
+
sessionStorage.setItem(
|
|
63
|
+
`cms-search:content-types:types:${CHANNEL_A}`,
|
|
64
|
+
JSON.stringify([
|
|
65
|
+
{ fqn: "sfdc_cms__ok", label: "Ok" },
|
|
66
|
+
{ fqn: "evil!!", label: "Bad" },
|
|
67
|
+
]),
|
|
68
|
+
);
|
|
69
|
+
expect(readCachedContentTypes(CHANNEL_A)).toEqual([{ fqn: "sfdc_cms__ok", label: "Ok" }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("degrades to null on corrupt JSON", () => {
|
|
73
|
+
sessionStorage.setItem(`cms-search:content-types:types:${CHANNEL_A}`, "{not json");
|
|
74
|
+
expect(readCachedContentTypes(CHANNEL_A)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("last channel pointer (keyed by UIBundle)", () => {
|
|
79
|
+
it("round-trips the last channel for a bundle", () => {
|
|
80
|
+
writeLastChannelId(BUNDLE_A, CHANNEL_A);
|
|
81
|
+
expect(readLastChannelId(BUNDLE_A)).toBe(CHANNEL_A);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("isolates the pointer per UIBundle — a second bundle never inherits the first", () => {
|
|
85
|
+
writeLastChannelId(BUNDLE_A, CHANNEL_A);
|
|
86
|
+
// A different bundle opened in the same session has no pointer yet.
|
|
87
|
+
expect(readLastChannelId(BUNDLE_B)).toBeNull();
|
|
88
|
+
// And writing B does not clobber A.
|
|
89
|
+
writeLastChannelId(BUNDLE_B, CHANNEL_B);
|
|
90
|
+
expect(readLastChannelId(BUNDLE_A)).toBe(CHANNEL_A);
|
|
91
|
+
expect(readLastChannelId(BUNDLE_B)).toBe(CHANNEL_B);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("writeCachedContentTypes does NOT set any last-channel pointer", () => {
|
|
95
|
+
writeCachedContentTypes(CHANNEL_A, TYPES);
|
|
96
|
+
expect(readLastChannelId(BUNDLE_A)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not write under a malformed UIBundle id", () => {
|
|
100
|
+
writeLastChannelId("not-a-bundle", CHANNEL_A);
|
|
101
|
+
expect(readLastChannelId("not-a-bundle")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not write under a malformed channel id", () => {
|
|
105
|
+
writeLastChannelId(BUNDLE_A, "not-a-channel");
|
|
106
|
+
expect(readLastChannelId(BUNDLE_A)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null when the stored channel shape is invalid", () => {
|
|
110
|
+
sessionStorage.setItem(`cms-search:content-types:last-channel-id:${BUNDLE_A}`, "garbage");
|
|
111
|
+
expect(readLastChannelId(BUNDLE_A)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tests for contentTypeUtils — CMS content type formatting and validation.
|
|
9
|
+
* Ported from the CMS reference (`…/global-search/utils/__tests__/contentTypeUtils.test.ts`).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { formatContentTypeLabel, isValidCmsFqn, CMS_FQN_PATTERN } from "../contentTypeUtils";
|
|
13
|
+
|
|
14
|
+
describe("contentTypeUtils", () => {
|
|
15
|
+
describe("formatContentTypeLabel", () => {
|
|
16
|
+
it("formats single-word FQN by capitalizing first letter", () => {
|
|
17
|
+
expect(formatContentTypeLabel("sfdc_cms__news")).toBe("News");
|
|
18
|
+
expect(formatContentTypeLabel("sfdc_cms__document")).toBe("Document");
|
|
19
|
+
expect(formatContentTypeLabel("sfdc_cms__image")).toBe("Image");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("formats camelCase FQN by splitting words and title-casing", () => {
|
|
23
|
+
expect(formatContentTypeLabel("sfdc_cms__blogPost")).toBe("Blog Post");
|
|
24
|
+
expect(formatContentTypeLabel("sfdc_cms__pressRelease")).toBe("Press Release");
|
|
25
|
+
expect(formatContentTypeLabel("sfdc_cms__knowledgeArticle")).toBe("Knowledge Article");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("handles multiple camelCase words correctly", () => {
|
|
29
|
+
expect(formatContentTypeLabel("sfdc_cms__productCatalogItem")).toBe("Product Catalog Item");
|
|
30
|
+
expect(formatContentTypeLabel("sfdc_cms__videoContentAsset")).toBe("Video Content Asset");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles edge cases gracefully", () => {
|
|
34
|
+
expect(formatContentTypeLabel("news")).toBe("News");
|
|
35
|
+
expect(formatContentTypeLabel("sfdc_cms__all_lowercase")).toBe("All_lowercase");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("isValidCmsFqn", () => {
|
|
40
|
+
it("returns true for valid CMS FQNs", () => {
|
|
41
|
+
expect(isValidCmsFqn("sfdc_cms__news")).toBe(true);
|
|
42
|
+
expect(isValidCmsFqn("sfdc_cms__blogPost")).toBe(true);
|
|
43
|
+
expect(isValidCmsFqn("sfdc_cms__document")).toBe(true);
|
|
44
|
+
expect(isValidCmsFqn("sfdc_cms__a")).toBe(true);
|
|
45
|
+
expect(isValidCmsFqn("sfdc_cms__a123456789012345678901234567890123456789")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns false for FQNs without correct prefix", () => {
|
|
49
|
+
expect(isValidCmsFqn("news")).toBe(false);
|
|
50
|
+
expect(isValidCmsFqn("cms__news")).toBe(false);
|
|
51
|
+
expect(isValidCmsFqn("sfdc_news")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns false for FQNs with invalid characters", () => {
|
|
55
|
+
expect(isValidCmsFqn("sfdc_cms__news-article")).toBe(false);
|
|
56
|
+
expect(isValidCmsFqn("sfdc_cms__news article")).toBe(false);
|
|
57
|
+
expect(isValidCmsFqn("sfdc_cms__news@item")).toBe(false);
|
|
58
|
+
expect(isValidCmsFqn("sfdc_cms__news_item")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false for overly long FQNs", () => {
|
|
62
|
+
expect(isValidCmsFqn("sfdc_cms__a1234567890123456789012345678901234567890")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns false for empty or malformed strings", () => {
|
|
66
|
+
expect(isValidCmsFqn("")).toBe(false);
|
|
67
|
+
expect(isValidCmsFqn("sfdc_cms__")).toBe(false);
|
|
68
|
+
expect(isValidCmsFqn("sfdc_cms__1starts_with_digit")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("CMS_FQN_PATTERN", () => {
|
|
73
|
+
it("is a RegExp instance", () => {
|
|
74
|
+
expect(CMS_FQN_PATTERN).toBeInstanceOf(RegExp);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("matches the expected pattern structure", () => {
|
|
78
|
+
expect(CMS_FQN_PATTERN.source).toContain("sfdc_cms__");
|
|
79
|
+
expect(CMS_FQN_PATTERN.source).toContain("[a-zA-Z]");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Tests for the CMS build-version gate. */
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
9
|
+
import { getOrgSupportsCmsSearch, MIN_CMS_API_VERSION } from "../api/orgApiVersionService";
|
|
10
|
+
|
|
11
|
+
// The build-time API version is compiled in via `__SF_API_VERSION__`; mock it so
|
|
12
|
+
// tests control the gate independently of the environment.
|
|
13
|
+
vi.mock("../api/apiUtils", () => ({
|
|
14
|
+
getBuildApiVersion: vi.fn(() => 68),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const { getBuildApiVersion } = await import("../api/apiUtils");
|
|
18
|
+
|
|
19
|
+
describe("orgApiVersionService", () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
// Default: a build new enough to pass the gate.
|
|
23
|
+
vi.mocked(getBuildApiVersion).mockReturnValue(68);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("MIN_CMS_API_VERSION is 68 (core 264)", () => {
|
|
27
|
+
expect(MIN_CMS_API_VERSION).toBe(68);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns true when the build version is exactly the floor (68)", async () => {
|
|
31
|
+
vi.mocked(getBuildApiVersion).mockReturnValue(68);
|
|
32
|
+
expect(await getOrgSupportsCmsSearch()).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns true when the build version is above the floor (69)", async () => {
|
|
36
|
+
vi.mocked(getBuildApiVersion).mockReturnValue(69);
|
|
37
|
+
expect(await getOrgSupportsCmsSearch()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns false when the build version is below the floor (e.g. 67)", async () => {
|
|
41
|
+
vi.mocked(getBuildApiVersion).mockReturnValue(67);
|
|
42
|
+
expect(await getOrgSupportsCmsSearch()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns false when the build version is unparseable (0)", async () => {
|
|
46
|
+
vi.mocked(getBuildApiVersion).mockReturnValue(0);
|
|
47
|
+
expect(await getOrgSupportsCmsSearch()).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("makes no network call — the gate is purely the build version", async () => {
|
|
51
|
+
// getBuildApiVersion is the only dependency; there is nothing else to spy on.
|
|
52
|
+
// A single call resolves the gate.
|
|
53
|
+
vi.mocked(getBuildApiVersion).mockReturnValue(69);
|
|
54
|
+
await getOrgSupportsCmsSearch();
|
|
55
|
+
expect(getBuildApiVersion).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tests for the CMS parseResponse — offset → pageInfo math and partial-failure
|
|
9
|
+
* leniency (absent subtree → null, never throws).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { parseCmsResponse } from "../parseResponse";
|
|
13
|
+
import type { CmsSourceConfig } from "../types";
|
|
14
|
+
import type { SourceRequest } from "../../types";
|
|
15
|
+
|
|
16
|
+
const source: CmsSourceConfig = { kind: "cms", key: "content", label: "Content" };
|
|
17
|
+
|
|
18
|
+
function req(pageSize: number): SourceRequest<CmsSourceConfig> {
|
|
19
|
+
return { source, q: "hello", filters: [], sort: null, pageSize, afterCursor: undefined };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeItems(n: number) {
|
|
23
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
24
|
+
managedContentId: `id-${i}`,
|
|
25
|
+
managedContentKey: `key-${i}`,
|
|
26
|
+
title: `Item ${i}`,
|
|
27
|
+
contentType: "sfdc_cms__news",
|
|
28
|
+
managedContentChannelDeliveryDetails: [],
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function root(payload: unknown): Record<string, unknown> {
|
|
33
|
+
return { managed_content: { search: { searchContentInChannels: payload } } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("parseCmsResponse", () => {
|
|
37
|
+
it("first full page: hasNextPage true, hasPreviousPage false", () => {
|
|
38
|
+
const result = parseCmsResponse(
|
|
39
|
+
root({ total: 100, offset: 0, pageSize: 20, items: makeItems(20) }),
|
|
40
|
+
req(20),
|
|
41
|
+
);
|
|
42
|
+
expect(result).not.toBeNull();
|
|
43
|
+
expect(result!.nodes).toHaveLength(20);
|
|
44
|
+
expect(result!.totalCount).toBe(100);
|
|
45
|
+
expect(result!.pageInfo).toEqual({
|
|
46
|
+
hasNextPage: true,
|
|
47
|
+
hasPreviousPage: false,
|
|
48
|
+
startCursor: "0",
|
|
49
|
+
endCursor: "20",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("middle page: both next and previous true, cursors advance by pageSize", () => {
|
|
54
|
+
const result = parseCmsResponse(
|
|
55
|
+
root({ total: 100, offset: 20, pageSize: 20, items: makeItems(20) }),
|
|
56
|
+
req(20),
|
|
57
|
+
);
|
|
58
|
+
expect(result!.pageInfo).toEqual({
|
|
59
|
+
hasNextPage: true,
|
|
60
|
+
hasPreviousPage: true,
|
|
61
|
+
startCursor: "20",
|
|
62
|
+
endCursor: "40",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("last partial page: hasNextPage false", () => {
|
|
67
|
+
const result = parseCmsResponse(
|
|
68
|
+
root({ total: 100, offset: 80, pageSize: 20, items: makeItems(20) }),
|
|
69
|
+
req(20),
|
|
70
|
+
);
|
|
71
|
+
expect(result!.pageInfo!.hasNextPage).toBe(false);
|
|
72
|
+
expect(result!.pageInfo!.hasPreviousPage).toBe(true);
|
|
73
|
+
expect(result!.pageInfo!.startCursor).toBe("80");
|
|
74
|
+
expect(result!.pageInfo!.endCursor).toBe("100");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("empty result: zero nodes, no next/previous", () => {
|
|
78
|
+
const result = parseCmsResponse(
|
|
79
|
+
root({ total: 0, offset: 0, pageSize: 20, items: [] }),
|
|
80
|
+
req(20),
|
|
81
|
+
);
|
|
82
|
+
expect(result!.nodes).toHaveLength(0);
|
|
83
|
+
expect(result!.totalCount).toBe(0);
|
|
84
|
+
expect(result!.pageInfo).toEqual({
|
|
85
|
+
hasNextPage: false,
|
|
86
|
+
hasPreviousPage: false,
|
|
87
|
+
startCursor: "0",
|
|
88
|
+
endCursor: "20",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null when the managed_content subtree is absent", () => {
|
|
93
|
+
expect(parseCmsResponse({}, req(20))).toBeNull();
|
|
94
|
+
expect(parseCmsResponse({ managed_content: {} }, req(20))).toBeNull();
|
|
95
|
+
expect(parseCmsResponse({ managed_content: { search: {} } }, req(20))).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns null (does not throw) when the subtree is null/errored", () => {
|
|
99
|
+
expect(() => parseCmsResponse(root(null), req(20))).not.toThrow();
|
|
100
|
+
expect(parseCmsResponse(root(null), req(20))).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Tests for searchChannel UIBundle id resolution and validation. */
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
9
|
+
import { getUIBundleId, isConfiguredUIBundleId } from "../searchChannel";
|
|
10
|
+
import type { CurrentApp } from "@salesforce/platform-sdk";
|
|
11
|
+
|
|
12
|
+
vi.mock("@salesforce/platform-sdk", () => ({
|
|
13
|
+
getCurrentApp: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { getCurrentApp } = await import("@salesforce/platform-sdk");
|
|
17
|
+
|
|
18
|
+
describe("searchChannel", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("getUIBundleId", () => {
|
|
24
|
+
it("returns bundleId when available in hosted environment", async () => {
|
|
25
|
+
const mockApp: CurrentApp = {
|
|
26
|
+
identity: {
|
|
27
|
+
namespace: "MyNs",
|
|
28
|
+
appName: "MyBundle",
|
|
29
|
+
qualifiedName: "MyNs__MyBundle",
|
|
30
|
+
bundleId: "0apSG000002dvWjYAI",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
vi.mocked(getCurrentApp).mockResolvedValue(mockApp);
|
|
34
|
+
|
|
35
|
+
const result = await getUIBundleId();
|
|
36
|
+
|
|
37
|
+
expect(result).toBe("0apSG000002dvWjYAI");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns empty string when bundleId is undefined", async () => {
|
|
41
|
+
const mockApp: CurrentApp = {
|
|
42
|
+
identity: {
|
|
43
|
+
namespace: "c",
|
|
44
|
+
appName: "LocalApp",
|
|
45
|
+
qualifiedName: "c__LocalApp",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
vi.mocked(getCurrentApp).mockResolvedValue(mockApp);
|
|
49
|
+
|
|
50
|
+
const result = await getUIBundleId();
|
|
51
|
+
|
|
52
|
+
expect(result).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns empty string when identity is undefined", async () => {
|
|
56
|
+
const mockApp: CurrentApp = {};
|
|
57
|
+
vi.mocked(getCurrentApp).mockResolvedValue(mockApp);
|
|
58
|
+
|
|
59
|
+
const result = await getUIBundleId();
|
|
60
|
+
|
|
61
|
+
expect(result).toBe("");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty string when getCurrentApp returns empty object", async () => {
|
|
65
|
+
vi.mocked(getCurrentApp).mockResolvedValue({});
|
|
66
|
+
|
|
67
|
+
const result = await getUIBundleId();
|
|
68
|
+
|
|
69
|
+
expect(result).toBe("");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("isConfiguredUIBundleId", () => {
|
|
74
|
+
it("returns true for a well-formed 9YE-shaped UIBundle id", () => {
|
|
75
|
+
expect(isConfiguredUIBundleId("9YE000001abcdEFGHi")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns false for a 0ap-shaped (wrong prefix) id", () => {
|
|
79
|
+
expect(isConfiguredUIBundleId("0apSG000002dvWjYAI")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns false for an arbitrary non-empty string", () => {
|
|
83
|
+
expect(isConfiguredUIBundleId("test123")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns false for empty string", () => {
|
|
87
|
+
expect(isConfiguredUIBundleId("")).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns false for whitespace-only string", () => {
|
|
91
|
+
expect(isConfiguredUIBundleId(" ")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns false for tab-only string", () => {
|
|
95
|
+
expect(isConfiguredUIBundleId("\t\t")).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tests for fetchSearchableContentTypes — pagination loop, invalid channelId
|
|
9
|
+
* throwing before any fetch, non-searchable / invalid-FQN filtering, and the
|
|
10
|
+
* hard-coded pageSize=25 on every request.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
13
|
+
|
|
14
|
+
const connectGetJson = vi.fn();
|
|
15
|
+
|
|
16
|
+
vi.mock("../api/apiUtils", () => ({
|
|
17
|
+
connectGetJson: (url: string) => connectGetJson(url),
|
|
18
|
+
// keep the real path helpers so URL assertions are meaningful
|
|
19
|
+
connectUrl: (subPath: string) => `/services/data/v65.0/connect${subPath}`,
|
|
20
|
+
safeEncodePath: (s: string) => encodeURIComponent(s),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { fetchSearchableContentTypes } from "../api/searchableContentTypesService";
|
|
24
|
+
|
|
25
|
+
const VALID_CHANNEL = "0ap000000000001";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds one raw entry in the real Connect v68 shape: the prefixed FQN is in
|
|
29
|
+
* `id`, `name` is the bare developer name, `label` is the display name.
|
|
30
|
+
*/
|
|
31
|
+
function entry(fqn: string, searchable = true, label?: string) {
|
|
32
|
+
const bare = fqn.replace(/^sfdc_cms__/, "");
|
|
33
|
+
return {
|
|
34
|
+
id: fqn,
|
|
35
|
+
name: bare,
|
|
36
|
+
label: label ?? bare.charAt(0).toUpperCase() + bare.slice(1),
|
|
37
|
+
isSearchable: searchable,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
connectGetJson.mockReset();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("fetchSearchableContentTypes", () => {
|
|
46
|
+
it("throws on an invalid channel id before any fetch", async () => {
|
|
47
|
+
await expect(fetchSearchableContentTypes("bad-id")).rejects.toThrow(/Invalid CMS channel id/);
|
|
48
|
+
expect(connectGetJson).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("paginates: a full page (25) then a short page, concatenating results", async () => {
|
|
52
|
+
const fullPage = Array.from({ length: 25 }, (_, i) => entry(`sfdc_cms__type${i}`));
|
|
53
|
+
const shortPage = [entry("sfdc_cms__news"), entry("sfdc_cms__blogPost")];
|
|
54
|
+
connectGetJson
|
|
55
|
+
.mockResolvedValueOnce({ items: fullPage })
|
|
56
|
+
.mockResolvedValueOnce({ items: shortPage });
|
|
57
|
+
|
|
58
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
59
|
+
const fqns = result.map((ct) => ct.fqn);
|
|
60
|
+
|
|
61
|
+
expect(connectGetJson).toHaveBeenCalledTimes(2);
|
|
62
|
+
expect(result).toHaveLength(27);
|
|
63
|
+
expect(fqns).toContain("sfdc_cms__news");
|
|
64
|
+
expect(fqns).toContain("sfdc_cms__blogPost");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("stops after the first page when it is short", async () => {
|
|
68
|
+
connectGetJson.mockResolvedValueOnce({ items: [entry("sfdc_cms__news")] });
|
|
69
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
70
|
+
expect(connectGetJson).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(result).toEqual([{ fqn: "sfdc_cms__news", label: "News" }]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses the server-provided label (not one derived from the name)", async () => {
|
|
75
|
+
connectGetJson.mockResolvedValueOnce({
|
|
76
|
+
items: [
|
|
77
|
+
{
|
|
78
|
+
id: "sfdc_cms__pressRelease",
|
|
79
|
+
name: "pressRelease",
|
|
80
|
+
label: "Press Release",
|
|
81
|
+
isSearchable: true,
|
|
82
|
+
},
|
|
83
|
+
{ id: "sfdc_cms__image", name: "image", label: "Image", isSearchable: true },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
87
|
+
expect(result).toEqual([
|
|
88
|
+
{ fqn: "sfdc_cms__pressRelease", label: "Press Release" },
|
|
89
|
+
{ fqn: "sfdc_cms__image", label: "Image" },
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls back to a derived label when the server omits one", async () => {
|
|
94
|
+
connectGetJson.mockResolvedValueOnce({
|
|
95
|
+
items: [{ id: "sfdc_cms__blogPost", name: "blogPost", isSearchable: true }],
|
|
96
|
+
});
|
|
97
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
98
|
+
expect(result).toEqual([{ fqn: "sfdc_cms__blogPost", label: "Blog Post" }]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("reads the FQN from id, not the bare name (which would fail FQN validation)", async () => {
|
|
102
|
+
connectGetJson.mockResolvedValueOnce({
|
|
103
|
+
// Real shape: id carries the prefixed FQN, name is bare.
|
|
104
|
+
items: [{ id: "sfdc_cms__news", name: "news", label: "News", isSearchable: true }],
|
|
105
|
+
});
|
|
106
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
107
|
+
expect(result).toEqual([{ fqn: "sfdc_cms__news", label: "News" }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("filters out non-searchable and invalid-FQN entries", async () => {
|
|
111
|
+
connectGetJson.mockResolvedValueOnce({
|
|
112
|
+
items: [
|
|
113
|
+
entry("sfdc_cms__news", true),
|
|
114
|
+
entry("sfdc_cms__hidden", false), // not searchable
|
|
115
|
+
{ id: "0jNxx0000000001", name: "legacy", label: "Legacy", isSearchable: true }, // id is a bare entity id, not an FQN
|
|
116
|
+
entry("sfdc_cms__blogPost", true, "Blog Post"),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
120
|
+
expect(result).toEqual([
|
|
121
|
+
{ fqn: "sfdc_cms__news", label: "News" },
|
|
122
|
+
{ fqn: "sfdc_cms__blogPost", label: "Blog Post" },
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("requests pageSize=25 on every call and increments page", async () => {
|
|
127
|
+
const fullPage = Array.from({ length: 25 }, (_, i) => entry(`sfdc_cms__type${i}`));
|
|
128
|
+
connectGetJson.mockResolvedValueOnce({ items: fullPage }).mockResolvedValueOnce({ items: [] });
|
|
129
|
+
|
|
130
|
+
await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
131
|
+
|
|
132
|
+
const urls = connectGetJson.mock.calls.map((c) => c[0] as string);
|
|
133
|
+
expect(urls[0]).toContain("page=0");
|
|
134
|
+
expect(urls[0]).toContain("pageSize=25");
|
|
135
|
+
expect(urls[1]).toContain("page=1");
|
|
136
|
+
expect(urls[1]).toContain("pageSize=25");
|
|
137
|
+
expect(urls.every((u) => u.includes("pageSize=25"))).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("accepts a top-level array payload", async () => {
|
|
141
|
+
connectGetJson.mockResolvedValueOnce([entry("sfdc_cms__news")]);
|
|
142
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
143
|
+
expect(result).toEqual([{ fqn: "sfdc_cms__news", label: "News" }]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("de-duplicates repeated FQNs", async () => {
|
|
147
|
+
connectGetJson.mockResolvedValueOnce({
|
|
148
|
+
items: [entry("sfdc_cms__news"), entry("sfdc_cms__news")],
|
|
149
|
+
});
|
|
150
|
+
const result = await fetchSearchableContentTypes(VALID_CHANNEL);
|
|
151
|
+
expect(result).toEqual([{ fqn: "sfdc_cms__news", label: "News" }]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained CMS Connect REST helpers (URL builder + authenticated GET).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createDataSDK } from "@salesforce/platform-sdk";
|
|
6
|
+
|
|
7
|
+
// The build injects the org's API version via `__SF_API_VERSION__`; the literal
|
|
8
|
+
// is only a fallback for environments (e.g. unit tests) where it is undefined.
|
|
9
|
+
declare const __SF_API_VERSION__: string;
|
|
10
|
+
|
|
11
|
+
/** Project-standard API version, mirroring `@salesforce/ui-bundle/api`'s clients. */
|
|
12
|
+
const API_VERSION: string = typeof __SF_API_VERSION__ !== "undefined" ? __SF_API_VERSION__ : "65.0";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The bundle's build-time API version as a number (e.g. `68.0`), i.e. the
|
|
16
|
+
* version the SDK's GraphQL/REST requests are actually issued at. `0` when it
|
|
17
|
+
* cannot be parsed. Distinct from the org's advertised max version.
|
|
18
|
+
*/
|
|
19
|
+
export function getBuildApiVersion(): number {
|
|
20
|
+
const v = Number.parseFloat(API_VERSION);
|
|
21
|
+
return Number.isFinite(v) ? v : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Percent-encodes a single path segment (SSRF / path-injection guard). Mirrors
|
|
26
|
+
* the reference `safeEncodePath`.
|
|
27
|
+
*/
|
|
28
|
+
export function safeEncodePath(segment: string): string {
|
|
29
|
+
return encodeURIComponent(segment);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Builds an absolute Connect REST URL from a version-relative sub-path. */
|
|
33
|
+
export function connectUrl(subPath: string): string {
|
|
34
|
+
const normalized = subPath.startsWith("/") ? subPath : `/${subPath}`;
|
|
35
|
+
return `/services/data/v${API_VERSION}/connect${normalized}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* GETs a Connect JSON resource via the platform SDK's authenticated `fetch`
|
|
40
|
+
* and returns the parsed body. Throws on SDK-init failure or a non-OK response
|
|
41
|
+
* (the caller decides how a whole-request failure surfaces).
|
|
42
|
+
*/
|
|
43
|
+
export async function connectGetJson<T = unknown>(url: string): Promise<T> {
|
|
44
|
+
const sdk = await createDataSDK();
|
|
45
|
+
if (!sdk?.fetch) {
|
|
46
|
+
throw new Error("Failed to initialize data SDK for CMS REST call");
|
|
47
|
+
}
|
|
48
|
+
const response = await sdk.fetch(url, {
|
|
49
|
+
method: "GET",
|
|
50
|
+
headers: { Accept: "application/json" },
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new Error(`CMS REST request failed: ${response.status} ${response.statusText}`);
|
|
54
|
+
}
|
|
55
|
+
return (await response.json()) as T;
|
|
56
|
+
}
|