@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.
Files changed (52) hide show
  1. package/dist/CHANGELOG.md +8 -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
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [11.56.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.55.1...v11.56.0) (2026-08-12)
7
+
8
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
9
+
10
+
11
+
12
+
13
+
6
14
  ## [11.55.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.55.0...v11.55.1) (2026-08-11)
7
15
 
8
16
  **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
@@ -18,8 +18,8 @@
18
18
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@salesforce/platform-sdk": "^11.55.1",
22
- "@salesforce/ui-bundle": "^11.55.1",
21
+ "@salesforce/platform-sdk": "^11.56.0",
22
+ "@salesforce/ui-bundle": "^11.56.0",
23
23
  "@tailwindcss/vite": "^4.1.17",
24
24
  "class-variance-authority": "^0.7.1",
25
25
  "clsx": "^2.1.1",
@@ -46,8 +46,8 @@
46
46
  "@graphql-eslint/eslint-plugin": "^4.1.0",
47
47
  "@graphql-tools/utils": "^11.0.0",
48
48
  "@playwright/test": "^1.49.0",
49
- "@salesforce/graphiti": "^11.55.1",
50
- "@salesforce/vite-plugin-ui-bundle": "^11.55.1",
49
+ "@salesforce/graphiti": "^11.56.0",
50
+ "@salesforce/vite-plugin-ui-bundle": "^11.56.0",
51
51
  "@testing-library/jest-dom": "^6.6.3",
52
52
  "@testing-library/react": "^16.1.0",
53
53
  "@testing-library/user-event": "^14.5.2",
@@ -0,0 +1,235 @@
1
+ # Search feature
2
+
3
+ Explains how this search feature is structured and how to customize it — which
4
+ files own configuration, how sObject and CMS sources differ, how CMS search is
5
+ wired end to end, and how to mount the UI. Read it before changing anything
6
+ under this directory.
7
+
8
+ ## 1. What this feature is / where sources live
9
+
10
+ - `config.json` (co-located here) declares `sources: SourceConfig[]` — a
11
+ discriminated union on `kind` — plus an optional `pagination` block. The
12
+ shipped default has 3 `"sobject"` sources (`accounts`, `contacts`,
13
+ `opportunities`) and one `"cms"` source (`key: "content"`).
14
+ - `loadConfig.ts` re-exports `config.json`, typed as `SearchConfig`. For a
15
+ pure config change (add/remove a source, tweak fields), **edit
16
+ `config.json` directly — no code changes required.**
17
+ - `types.ts` is the source of truth for `SourceConfig` (`SObjectSourceConfig |
18
+ CmsSourceConfig`) and `SearchConfig`.
19
+
20
+ ## 2. Choosing sObject vs CMS per source
21
+
22
+ - **sObject source** (`kind: "sobject"`): requires `objectName`, `label`,
23
+ `searchableFields`, `displayFields`. Optional: `labelSingular`, `idField`
24
+ (defaults `"Id"`), `routePattern`, `filterBy`, `sortBy`, `defaultSort`,
25
+ `whereTypeName`/`orderByTypeName`. Queried via the `uiapi` GraphQL bridge
26
+ (`adapters/sobject`).
27
+ - **CMS source** (`kind: "cms"`): deliberately minimal — only `kind`, `key`,
28
+ `label`, and optionally `labelSingular`/`routePattern`
29
+ (`adapters/cms/types.ts`, `CmsSourceConfig`). No `channelId`, no content
30
+ types, no `displayFields` — all of that is resolved at runtime. Routed via
31
+ `adapters/registry.ts` to `cmsAdapter` (`adapters/cms/index.ts`).
32
+ - `adapters/registry.ts` maps `kind` → adapter (`sobject → sObjectAdapter`,
33
+ `cms → cmsAdapter`). Adding a new backend `kind` means registering an
34
+ adapter here plus extending the `SourceConfig` union in `types.ts` — out of
35
+ scope for typical customization; most tasks only touch `config.json`.
36
+
37
+ ## 3. Enabling sObject search
38
+
39
+ sObject sources work out of the box — no runtime id resolution and no gating.
40
+ They query the org's `uiapi` GraphQL bridge through `adapters/sobject`.
41
+
42
+ - **Declare the source** in `config.json`: `kind: "sobject"`, `objectName` (the
43
+ API name, e.g. `"Account"`), `label`, `searchableFields` (the fields the
44
+ free-text term matches against), and `displayFields` (what each result row
45
+ shows). A `displayFields` entry is a plain field name (`"Name"`), a
46
+ `{ name, raw }` (use the raw value, skip display formatting), or a
47
+ `{ name, subfields }` for a parent relationship
48
+ (`{ "name": "Owner", "subfields": ["Name"] }`).
49
+ - **Optional per-source knobs:** `labelSingular`, `idField` (defaults `"Id"`),
50
+ `filterBy` (facets — `picklist` / `numeric` / `daterange`), `sortBy` +
51
+ `defaultSort`, and `routePattern` (see §6). `whereTypeName` / `orderByTypeName`
52
+ override the generated GraphQL input type names for objects whose where/orderBy
53
+ types don't follow the default `<Object>` naming.
54
+ - **Query construction:** `buildSearchQuery` (`api/buildSearchQuery.ts`)
55
+ assembles the GraphQL from the source config, `buildOrderBy` builds the sort
56
+ clause, and picklist filter values come from `useDistinctValues` /
57
+ `fetchDistinctValues`. The whole path runs through the platform data SDK — no
58
+ enablement step beyond declaring the source.
59
+ - **FLS caveat:** a field the running user can't read is silently dropped by the
60
+ platform, so an sObject source that "returns nothing" usually means the app's
61
+ permission set is missing field access, not a config error.
62
+
63
+ ## 4. Enabling CMS search
64
+
65
+ This is the part most likely to be missed — read all of it before assuming
66
+ CMS search "just works" after adding a `cms` source to `config.json`.
67
+
68
+ - **API version requirement.** Search on CMS content is supported only when the
69
+ bundle is built against **API version 68.0 or greater**. The build-time
70
+ version (`__SF_API_VERSION__`, injected from the resolved org's API version)
71
+ is the version every SDK request is actually issued at, so it is the gate.
72
+ - **No static id in config.** The UIBundle id is resolved at runtime by
73
+ `adapters/cms/searchChannel.ts::getUIBundleId()`, which calls
74
+ `@salesforce/platform-sdk`'s `getCurrentApp()` and reads
75
+ `identity.bundleId` — the UIBundle **record id** (a `9YE...` id). This is
76
+ only populated when running on the WebApp surface (the runtime injects
77
+ `SFDC_ENV`, per `packages/sdk/platform-sdk/src/core/app.ts`). Locally, or on
78
+ any non-WebApp surface, `bundleId` is `undefined`/`""`, and the CMS source
79
+ is skipped with no error — by design.
80
+ - **The gate:** `hooks/useSearch.ts` resolves `getUIBundleId()` **and**
81
+ `getOrgSupportsCmsSearch()` once per fetch and, for the CMS source, includes
82
+ the CMS request only when BOTH pass: `isConfiguredUIBundleId(uiBundleId)`
83
+ (`adapters/cms/searchChannel.ts`) and the build API-version check (above).
84
+ `isConfiguredUIBundleId` validates the `9YE` shape
85
+ (`UI_BUNDLE_ID_PATTERN = /^9YE[a-zA-Z0-9]{12,15}$/`) — an empty or malformed
86
+ id, or a build below v68, causes the CMS source to be silently dropped from
87
+ the request (no per-source error banner), not sent to the server.
88
+ - **The query field:** `adapters/cms/cmsQueryFragment.ts` sends the resolved
89
+ `$UIBundleId` into `searchIdentifiers.uibundleIds` (NOT `channelIds`) on
90
+ `managed_content.search.searchContentInChannels`. Content-type filtering
91
+ (`$cmsContentTypeFQNs`) is omitted on the bootstrap call and supplied once
92
+ discovery completes.
93
+ - **Why `uibundleIds` (evidence from core).** `ContentSearchIdentifiersInput`
94
+ has three id-space fields — `channelIds` (`0ap` Managed Content channel ids),
95
+ `siteIds`, and `uibundleIds` — and the `9YE` UIBundle **record id** belongs
96
+ only in `uibundleIds`. In core (`core-2206/core-266-public`): the schema
97
+ `ui-services-private/.../graphql-schemas/mcontent-search.graphqls` documents
98
+ `uibundleIds` as _"Each UI bundle ID is resolved to its associated Managed
99
+ Content channel(s)"_; the resolver
100
+ `mcontent-impl/.../ManagedContentGraphQLSearchServiceImpl.resolveChannelIdsFromSitesAndUiBundles()`
101
+ resolves each id via `getManagedContentChannelsByTarget(uibundleId)` (the id
102
+ is the channel's **TargetEntityId**, i.e. a UIBundle record id); the UDD
103
+ `lwr-udd/.../UIBundle.entity.xml` sets `keyPrefix="9YE"`; and the unit test
104
+ `ManagedContentGraphQLSearchServiceImplTest.testUiBundleId_resolvesToChannelIds_andIsSearched()`
105
+ passes a `9YE…` id into `uibundleIds` and asserts it resolves (W-23364628).
106
+ `channelIds` values are validated as channel/site ids and never run through
107
+ the target-entity resolver, so a `9YE` id sent there fails with
108
+ `"9YE… isn't a valid managed content channel ID or a site ID"` — the exact
109
+ error this wiring fixes. (Server-side, resolution of `uibundleIds`/`siteIds`
110
+ is gated by the core feature flag `isAllowTargetEntityIdsEnabled()`.)
111
+ - **Content-type discovery:** after the first CMS result returns,
112
+ `adapters/cms/channelResolver.ts::resolveChannelId()` extracts the `0ap`
113
+ managed-content-channel id from
114
+ `nodes[0].managedContentChannelDeliveryDetails[0].managedContentChannelDetails.id`.
115
+ `adapters/cms/hooks/useSearchableContentTypes.ts` (session-cached) then
116
+ fetches `GET /connect/cms/channels/{channelId}/searchable-content-types` to
117
+ populate the scope dropdown's per-content-type entries. Note: this `0ap` id
118
+ is a _different_ id space from the `9YE` UIBundle id above — don't confuse
119
+ the two when debugging.
120
+ - **Seeding content:** the resolved `bundleId` only surfaces content published
121
+ to the UIBundle's own channel (WebApp type), so CMS content must be published
122
+ there — not to the Experience Site COMMUNITY channel. Content published to
123
+ the wrong channel returns zero results even when the id/gate/query wiring is
124
+ correct, so empty CMS results with everything else in place usually points at
125
+ the publishing target rather than the code.
126
+ - **Mounting:** add a `{ "kind": "cms", "key": "...", "label": "..." }` entry
127
+ to `config.sources` (in `config.json`, or a custom `SearchConfig` object at
128
+ runtime), then render `<Search config={config} />` somewhere routed — see
129
+ the next section.
130
+
131
+ ## 5. Mounting `<Search>` — the routing caveat
132
+
133
+ - `GlobalSearchBox` (`components/GlobalSearchBox.tsx`) is a **pure
134
+ launcher** — a text input + button that calls `navigate('/search?q=...')`
135
+ on submit. **It renders no results itself.**
136
+ - **`GlobalSearchBox` only routes to `/search`; you must mount `<Search>` at
137
+ that route for results to render.** In this template, `routes.tsx` does
138
+ exactly that: `path: "search"` renders `<GlobalSearch config={config} .../>`
139
+ (`GlobalSearch` is `Search` aliased on import). If an app drops in
140
+ `GlobalSearchBox` without also mounting `<Search>` (or a custom results UI
141
+ built on `useSearch`) at the route it navigates to, searches will appear to
142
+ do nothing.
143
+ - `<Search>` itself (`components/Search.tsx`) is the batteries-included
144
+ drop-in. For narrower customizations (e.g. a single-object search page),
145
+ see its prop JSDoc for `restrictTo` (lock to one source), `renderResult` /
146
+ `renderFilters` (per-source overrides), and `showScopeSelector`.
147
+
148
+ ## 6. Adding a detail page (object detail / CMS content detail)
149
+
150
+ **By default, result cards are NOT clickable** — the feature renders each row as
151
+ plain (non-linked) text (see the final `return <div>…` branch in
152
+ `DefaultResultRow.tsx` / `CmsResultRow.tsx`). Making a card clickable is a
153
+ two-part change, and **both parts are required** — one without the other either
154
+ does nothing or 404s:
155
+
156
+ 1. **In this feature** (`config.json`): give the source a `routePattern`. This is
157
+ the _only_ thing that turns the row into a `<Link>`. Without it the row stays
158
+ non-clickable no matter what routes the app defines.
159
+ 2. **In the app** (`src/routes.tsx`): the detail page must actually exist and be
160
+ registered at a matching dynamic route. The feature only builds the href
161
+ (e.g. `/accounts/001…`); it does not own any route. If that route isn't in the
162
+ app's `routes.tsx`, the (now-clickable) card navigates to a dead URL and 404s.
163
+
164
+ So: `routePattern` present **and** a matching detail route/page in the app →
165
+ clickable card that opens a detail page. `routePattern` absent → non-clickable
166
+ card (the default). `routePattern` present but no app route → clickable card that
167
+ 404s.
168
+
169
+ - **How the link is built:** `routePattern` is a path template with `:token`
170
+ placeholders resolved per result.
171
+ - **sObject rows** (`components/results/DefaultResultRow.tsx`,
172
+ `resolveRoute(routePattern, node, idField)`): `:fieldName` tokens are
173
+ substituted from the record's field values, and a bare `:id` maps to the
174
+ source's `idField` (default `Id`). The shipped `accounts` source uses
175
+ `"/accounts/:id"`.
176
+ - **CMS rows** (`components/results/CmsResultRow.tsx`): tokens are `:id`
177
+ (the `managedContentId`) and `:key`; when no `routePattern` is set it falls
178
+ back to `/content/:contentType/:id`.
179
+ - **Authoring the app-side route + page** (part 2 above): register the matching
180
+ dynamic route in the app's `routes.tsx` (e.g. `path: "accounts/:id"` /
181
+ `path: "content/:contentType/:id"`). That route's component reads the route
182
+ param (`useParams`) and fetches the single record/content item — sObject
183
+ detail: fetch the one record by id via the platform data SDK (see the
184
+ `experience-ui-bundle-salesforce-data-access` skill — do not hand-roll
185
+ `fetch`); CMS detail: fetch the content item by `managedContentId` through the
186
+ CMS delivery API.
187
+ - **Generating the page:** whichever kind of detail page you need, nothing inside
188
+ this search feature changes beyond setting `routePattern` — the page and route
189
+ live in the app, not in this directory. Pick the skill by source type:
190
+ - **sObject object-detail page** — a normal routed page; generate it with the
191
+ `experience-ui-bundle-frontend-generate` skill (its page types include "detail
192
+ view"; record data is fetched via the platform Data SDK per the
193
+ `experience-ui-bundle-salesforce-data-access` skill).
194
+ - **CMS content-detail page** — generate it with the
195
+ `experience-cms-content-render` skill. That skill owns CMS content _fetch +
196
+ render_ (the delivery API, `contentKey`/`contentType`, RichText, and image
197
+ resolution) and **takes precedence** over `experience-ui-bundle-frontend-generate`
198
+ for the rendering portion — the UI skill owns only the page/layout shell. Its
199
+ Detail Page branch writes `src/pages/<type>/<PageName>.tsx` and inserts the
200
+ route, so the app gets both the page and the matching route wired for you. (It
201
+ is scoped to _rendering existing_ content — use this search feature, not that
202
+ skill, for the search itself.)
203
+
204
+ ## 7. Config knobs reference
205
+
206
+ | Knob | Values / notes |
207
+ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `pagination.mode` | `"per-source"` (default; one section per source) vs `"merged"` (one combined grid, single global pager) — `types.ts` |
209
+ | `pagination.mergeOrder` | `"sequential" \| "interleaved" \| "proportional"` — merged mode only |
210
+ | `pagination.pageSize` / `pageSizeOptions` | shared across every source, every scope |
211
+ | Per-sobject-source | `objectName`, `label`/`labelSingular`, `idField`, `routePattern`, `searchableFields`, `displayFields` (`string \| {name,raw} \| {name,subfields}`), `filterBy`, `sortBy`, `defaultSort`, `whereTypeName`/`orderByTypeName` |
212
+ | Per-cms-source | `key`, `label`, `labelSingular?`, `routePattern?` — nothing else, by design |
213
+ | `MIN_QUERY_LENGTH` | `constants.ts`, currently `3`. Both backends reject shorter terms server-side; gated client-side in `useSearch.ts` and `GlobalSearchBox.tsx` so the UI never shows a spurious failure for a too-short query |
214
+
215
+ ## 8. Public API pointers
216
+
217
+ Import from the barrel (`index.ts`) rather than reaching into internals. The
218
+ main extension points, grouped by concern:
219
+
220
+ - **Core (both source types):** `useSearch` — the hook (state, fetch,
221
+ pagination) behind `<Search>`; `runSearch` — the underlying fetch;
222
+ `buildSearchQuery` — assembles the query payload; `getAdapter` — resolves a
223
+ source `kind` to its adapter; `config` — the loaded `SearchConfig`.
224
+ - **sObject sources:** `useDistinctValues` / `fetchDistinctValues` — picklist
225
+ filter values; `buildOrderBy` — sort clause construction; the filter inputs
226
+ (`TextFilter`, `SelectFilter`, `MultiSelectFilter`, `NumericRangeFilter`,
227
+ `BooleanFilter`, `DateRangeFilter`) and `DefaultResultRow` for rendering.
228
+ - **CMS source:** `cmsAdapter`; `useSearchableContentTypes` — content-type
229
+ discovery; `isValidCmsFqn` / `formatContentTypeLabel` — content-type FQN
230
+ helpers; `CmsResultRow` for rendering.
231
+ - **Types:** `SearchConfig`, `SourceConfig`, `SObjectSourceConfig`,
232
+ `CmsSourceConfig`, `SourceAdapter`, `SearchHandle`, and related runtime types.
233
+
234
+ See `index.ts` for the full exported surface (all components, filter inputs,
235
+ utils) — this list is intentionally short.
@@ -0,0 +1,166 @@
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
+ * Integration tests for queryBuilder: ensures the combined document assembles
9
+ * uiapi + sibling managed_content correctly, and that SObject-only queries
10
+ * remain byte-identical to pre-CMS-adapter behavior (regression guard).
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import { buildSearchQuery } from "../queryBuilder";
14
+ import type { SourceRequest } from "../adapters/types";
15
+ import type { SObjectSourceConfig, CmsSourceConfig } from "../types";
16
+
17
+ const sobjectSource: SObjectSourceConfig = {
18
+ kind: "sobject",
19
+ key: "accounts",
20
+ label: "Accounts",
21
+ objectName: "Account",
22
+ displayFields: ["Name", "AccountNumber"],
23
+ searchableFields: ["Name", "AccountNumber"],
24
+ defaultSort: { field: "Name", direction: "ASC" },
25
+ };
26
+
27
+ const cmsSource: CmsSourceConfig = {
28
+ kind: "cms",
29
+ key: "content",
30
+ label: "Content",
31
+ };
32
+
33
+ describe("buildSearchQuery", () => {
34
+ it("throws when no requests provided", () => {
35
+ expect(() => buildSearchQuery([])).toThrow(
36
+ "buildSearchQuery requires at least one source request",
37
+ );
38
+ });
39
+
40
+ it("SObject-only request produces document with uiapi block only (no managed_content sibling)", () => {
41
+ const requests: SourceRequest[] = [
42
+ {
43
+ source: sobjectSource,
44
+ q: "acme",
45
+ filters: [],
46
+ sort: null,
47
+ pageSize: 20,
48
+ afterCursor: undefined,
49
+ },
50
+ ];
51
+
52
+ const { document, variables } = buildSearchQuery(requests);
53
+
54
+ // Must have a uiapi block
55
+ expect(document).toContain("uiapi {");
56
+ expect(document).toContain("query {");
57
+ expect(document).toContain("accounts: Account");
58
+
59
+ // Must NOT have a managed_content block (SObject-only)
60
+ expect(document).not.toContain("managed_content");
61
+
62
+ // Variables should contain SObject variables (with key-namespaced names)
63
+ expect(variables).toHaveProperty("accounts_first");
64
+ expect(variables).toHaveProperty("accounts_where");
65
+ expect(variables).not.toHaveProperty("cmsKeyword");
66
+ expect(variables).not.toHaveProperty("UIBundleId");
67
+ });
68
+
69
+ it("combined SObject + CMS request produces ONE document with uiapi + managed_content siblings", () => {
70
+ const requests: SourceRequest[] = [
71
+ {
72
+ source: sobjectSource,
73
+ q: "acme",
74
+ filters: [],
75
+ sort: null,
76
+ pageSize: 20,
77
+ afterCursor: undefined,
78
+ },
79
+ {
80
+ source: cmsSource,
81
+ q: "acme",
82
+ filters: [],
83
+ sort: null,
84
+ pageSize: 20,
85
+ afterCursor: undefined,
86
+ uiBundleId: "0ap000000000001",
87
+ },
88
+ ];
89
+
90
+ const { document, variables } = buildSearchQuery(requests);
91
+
92
+ // Must have BOTH uiapi and managed_content blocks as siblings
93
+ expect(document).toContain("uiapi {");
94
+ expect(document).toContain("query {");
95
+ expect(document).toContain("accounts: Account");
96
+ expect(document).toContain("managed_content {");
97
+ expect(document).toContain("searchContentInChannels");
98
+
99
+ // Variables should contain BOTH SObject and CMS variables
100
+ expect(variables).toHaveProperty("accounts_first");
101
+ expect(variables).toHaveProperty("cmsKeyword");
102
+ expect(variables).toHaveProperty("UIBundleId");
103
+ expect(variables).toHaveProperty("cmsOffset");
104
+ expect(variables).toHaveProperty("cmsLimit");
105
+
106
+ // Should be a single query Search(...) declaration
107
+ expect(document.match(/query Search\(/g)?.length).toBe(1);
108
+ });
109
+
110
+ it("CMS-only request (SObject sources dropped) produces document with managed_content only", () => {
111
+ const requests: SourceRequest[] = [
112
+ {
113
+ source: cmsSource,
114
+ q: "annual report",
115
+ filters: [],
116
+ sort: null,
117
+ pageSize: 20,
118
+ afterCursor: undefined,
119
+ uiBundleId: "0ap000000000001",
120
+ },
121
+ ];
122
+
123
+ const { document, variables } = buildSearchQuery(requests);
124
+
125
+ // Must have managed_content block
126
+ expect(document).toContain("managed_content {");
127
+ expect(document).toContain("searchContentInChannels");
128
+
129
+ // Must NOT have uiapi block (CMS-only, all SObject sources dropped)
130
+ expect(document).not.toContain("uiapi {");
131
+
132
+ // Variables should contain CMS variables only
133
+ expect(variables).toHaveProperty("cmsKeyword");
134
+ expect(variables).toHaveProperty("UIBundleId");
135
+ expect(variables).not.toHaveProperty("accounts_first");
136
+ });
137
+
138
+ it("bootstrap CMS request omits $cmsContentTypeFQNs from declarations and variables", () => {
139
+ const requests: SourceRequest[] = [
140
+ {
141
+ source: cmsSource,
142
+ q: "test",
143
+ filters: [],
144
+ sort: null,
145
+ pageSize: 20,
146
+ afterCursor: undefined,
147
+ uiBundleId: "0ap000000000001",
148
+ // No contentTypes provided (bootstrap)
149
+ },
150
+ ];
151
+
152
+ const { document, variables } = buildSearchQuery(requests);
153
+
154
+ // Should NOT declare $cmsContentTypeFQNs
155
+ expect(document).not.toContain("$cmsContentTypeFQNs");
156
+ expect(document).not.toContain("contentTypeFQNs:");
157
+
158
+ // Should NOT include cmsContentTypeFQNs in variables
159
+ expect(variables).not.toHaveProperty("cmsContentTypeFQNs");
160
+
161
+ // Should still have other CMS variables
162
+ expect(variables).toHaveProperty("cmsKeyword");
163
+ expect(variables).toHaveProperty("UIBundleId");
164
+ expect(variables).toHaveProperty("cmsLimit");
165
+ });
166
+ });
@@ -0,0 +1,73 @@
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 resolveChannelId — extracts the channel id from the first result's
9
+ * delivery details and returns null when any hop is missing.
10
+ */
11
+ import { describe, it, expect } from "vitest";
12
+ import { resolveChannelId } from "../channelResolver";
13
+ import type { SourceResult } from "../../../types";
14
+
15
+ function itemWithChannel(id: string) {
16
+ return {
17
+ managedContentId: "a",
18
+ managedContentKey: "k",
19
+ title: "t",
20
+ contentType: "sfdc_cms__news",
21
+ managedContentChannelDeliveryDetails: [{ managedContentChannelDetails: { id, name: "Ch" } }],
22
+ };
23
+ }
24
+
25
+ function sourceResult(nodes: unknown[]): SourceResult {
26
+ return { nodes, pageInfo: null, totalCount: nodes.length };
27
+ }
28
+
29
+ describe("resolveChannelId", () => {
30
+ it("returns the channel id from a valid SourceResult", () => {
31
+ expect(resolveChannelId(sourceResult([itemWithChannel("0ap000000000001")]))).toBe(
32
+ "0ap000000000001",
33
+ );
34
+ });
35
+
36
+ it("returns the channel id from a raw CmsSearchResponse (items)", () => {
37
+ expect(
38
+ resolveChannelId({
39
+ total: 1,
40
+ offset: 0,
41
+ pageSize: 20,
42
+ items: [itemWithChannel("0ap123456789012")],
43
+ }),
44
+ ).toBe("0ap123456789012");
45
+ });
46
+
47
+ it("returns null for empty nodes", () => {
48
+ expect(resolveChannelId(sourceResult([]))).toBeNull();
49
+ });
50
+
51
+ it("returns null when delivery details are missing", () => {
52
+ const node = {
53
+ managedContentId: "a",
54
+ managedContentKey: "k",
55
+ title: "t",
56
+ contentType: "sfdc_cms__news",
57
+ managedContentChannelDeliveryDetails: [],
58
+ };
59
+ expect(resolveChannelId(sourceResult([node]))).toBeNull();
60
+ });
61
+
62
+ it("returns null when channel details id is missing", () => {
63
+ const node = {
64
+ managedContentChannelDeliveryDetails: [{ managedContentChannelDetails: { name: "Ch" } }],
65
+ };
66
+ expect(resolveChannelId(sourceResult([node]))).toBeNull();
67
+ });
68
+
69
+ it("returns null for null / undefined input", () => {
70
+ expect(resolveChannelId(null)).toBeNull();
71
+ expect(resolveChannelId(undefined)).toBeNull();
72
+ });
73
+ });
@@ -0,0 +1,127 @@
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 buildCmsQueryFragment (via the cmsAdapter and directly) — the
9
+ * CONDITIONAL $cmsContentTypeFQNs rule, variable parameterization (no
10
+ * interpolation), root placement, and afterCursor → offset decoding.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import { buildCmsQueryFragment } from "../cmsQueryFragment";
14
+ import { cmsAdapter } from "../index";
15
+ import type { CmsSourceConfig } from "../types";
16
+ import type { SourceRequest } from "../../types";
17
+
18
+ const source: CmsSourceConfig = { kind: "cms", key: "content", label: "Content" };
19
+
20
+ function req(
21
+ overrides: Partial<SourceRequest<CmsSourceConfig>> = {},
22
+ ): SourceRequest<CmsSourceConfig> {
23
+ return {
24
+ source,
25
+ q: "annual report",
26
+ filters: [],
27
+ sort: null,
28
+ pageSize: 20,
29
+ afterCursor: undefined,
30
+ uiBundleId: "0ap000000000001",
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe("buildCmsQueryFragment", () => {
36
+ it("bootstrap (no content types): omits $cmsContentTypeFQNs entirely", () => {
37
+ const c = buildCmsQueryFragment({
38
+ keyword: "hi",
39
+ uiBundleId: "0ap1",
40
+ offset: 0,
41
+ limit: 20,
42
+ });
43
+ expect(c.placement).toBe("root");
44
+ expect(c.variableDeclarations).toEqual([
45
+ "$cmsKeyword: String!",
46
+ "$UIBundleId: ID!",
47
+ "$cmsOffset: Int!",
48
+ "$cmsLimit: Int!",
49
+ ]);
50
+ expect(c.variables).not.toHaveProperty("cmsContentTypeFQNs");
51
+ expect(c.fragment).not.toContain("$cmsContentTypeFQNs");
52
+ expect(c.fragment).not.toContain("contentTypeFQNs:");
53
+ });
54
+
55
+ it("typed call: declares, passes and supplies $cmsContentTypeFQNs", () => {
56
+ const c = buildCmsQueryFragment({
57
+ keyword: "hi",
58
+ uiBundleId: "0ap1",
59
+ offset: 40,
60
+ limit: 20,
61
+ contentTypes: ["sfdc_cms__news"],
62
+ });
63
+ expect(c.variableDeclarations).toContain("$cmsContentTypeFQNs: [String!]");
64
+ expect(c.variables.cmsContentTypeFQNs).toEqual(["sfdc_cms__news"]);
65
+ // The schema argument is `contentTypeFQNs`, NOT `contentTypes`.
66
+ expect(c.fragment).toContain("contentTypeFQNs: $cmsContentTypeFQNs");
67
+ expect(c.fragment).not.toContain("contentTypes:");
68
+ });
69
+
70
+ it("empty content-type array is treated as bootstrap (omitted)", () => {
71
+ const c = buildCmsQueryFragment({
72
+ keyword: "hi",
73
+ uiBundleId: "0ap1",
74
+ offset: 0,
75
+ limit: 20,
76
+ contentTypes: [],
77
+ });
78
+ expect(c.variableDeclarations).not.toContain("$cmsContentTypeFQNs: [String!]");
79
+ expect(c.fragment).not.toContain("contentTypeFQNs:");
80
+ });
81
+
82
+ it("parameterizes limit as a variable (never interpolates a page size)", () => {
83
+ const c = buildCmsQueryFragment({ keyword: "hi", uiBundleId: "0ap1", offset: 0, limit: 50 });
84
+ expect(c.fragment).toContain("pagination: { limit: $cmsLimit, offset: $cmsOffset }");
85
+ expect(c.fragment).not.toMatch(/limit:\s*\d/);
86
+ expect(c.variables.cmsLimit).toBe(50);
87
+ });
88
+
89
+ it("uses $UIBundleId in searchIdentifiers (not $cmsChannelId)", () => {
90
+ const c = buildCmsQueryFragment({ keyword: "hi", uiBundleId: "0ap1", offset: 0, limit: 20 });
91
+ expect(c.fragment).toContain("searchIdentifiers: { uibundleIds: [$UIBundleId] }");
92
+ expect(c.fragment).not.toContain("channelIds: [$UIBundleId]");
93
+ expect(c.fragment).not.toContain("$cmsChannelId");
94
+ expect(c.variables.UIBundleId).toBe("0ap1");
95
+ });
96
+ });
97
+
98
+ describe("cmsAdapter.buildRequest", () => {
99
+ it("kind is cms", () => {
100
+ expect(cmsAdapter.kind).toBe("cms");
101
+ });
102
+
103
+ it("decodes afterCursor to $cmsOffset and sets $cmsLimit from pageSize", () => {
104
+ const c = cmsAdapter.buildRequest(req({ afterCursor: "40", pageSize: 25 }));
105
+ expect(c.variables.cmsOffset).toBe(40);
106
+ expect(c.variables.cmsLimit).toBe(25);
107
+ expect(c.variables.cmsKeyword).toBe("annual report");
108
+ expect(c.variables.UIBundleId).toBe("0ap000000000001");
109
+ });
110
+
111
+ it("clamps a negative / non-numeric cursor to 0", () => {
112
+ expect(cmsAdapter.buildRequest(req({ afterCursor: "-5" })).variables.cmsOffset).toBe(0);
113
+ expect(cmsAdapter.buildRequest(req({ afterCursor: "abc" })).variables.cmsOffset).toBe(0);
114
+ expect(cmsAdapter.buildRequest(req({ afterCursor: undefined })).variables.cmsOffset).toBe(0);
115
+ });
116
+
117
+ it("passes valid content types through and drops invalid FQNs", () => {
118
+ const c = cmsAdapter.buildRequest(req({ contentTypes: ["sfdc_cms__news", "not-valid"] }));
119
+ expect(c.variables.cmsContentTypeFQNs).toEqual(["sfdc_cms__news"]);
120
+ });
121
+
122
+ it("omits content types when all are invalid (falls back to bootstrap)", () => {
123
+ const c = cmsAdapter.buildRequest(req({ contentTypes: ["not-valid"] }));
124
+ expect(c.variables).not.toHaveProperty("cmsContentTypeFQNs");
125
+ expect(c.variableDeclarations).not.toContain("$cmsContentTypeFQNs: [String!]");
126
+ });
127
+ });