@salesforce/ui-bundle-template-app-react-template-b2e 11.55.1 → 11.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/README.md +235 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/apiUtils.ts +56 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/orgApiVersionService.ts +26 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/channelResolver.ts +40 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/cmsQueryFragment.ts +97 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeSessionCache.ts +134 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/contentTypeUtils.ts +38 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/index.ts +54 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/parseResponse.ts +65 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/searchChannel.ts +15 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/cms/types.ts +66 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/registry.ts +38 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/index.ts +19 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/parseResponse.ts +56 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/sobject/queryFragment.ts +141 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/adapters/types.ts +101 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/api/searchService.ts +110 -45
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/GlobalSearchBox.tsx +98 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/MergedSearchResults.tsx +34 -17
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/Search.tsx +31 -13
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SearchResults.tsx +17 -11
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/SourceSection.tsx +9 -4
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/controls/ScopeSelector.tsx +50 -6
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/CmsResultRow.tsx +101 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/components/results/resolveResultRenderer.ts +49 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/config.json +7 -2
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/constants.ts +8 -0
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/hooks/useSearch.ts +313 -50
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/index.ts +26 -1
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/queryBuilder.ts +62 -118
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/features/search/types.ts +74 -5
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/Home.tsx +3 -42
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/routes.tsx +12 -5
- package/dist/force-app/main/default/uiBundles/reactinternalapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +4 -2
- package/dist/force-app/main/default/uiBundles/reactinternalapp/src/pages/AccountSearch.tsx +0 -25
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.
|
|
22
|
-
"@salesforce/ui-bundle": "^11.
|
|
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",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
46
46
|
"@graphql-tools/utils": "^11.0.0",
|
|
47
47
|
"@playwright/test": "^1.49.0",
|
|
48
|
-
"@salesforce/graphiti": "^11.
|
|
49
|
-
"@salesforce/vite-plugin-ui-bundle": "^11.
|
|
48
|
+
"@salesforce/graphiti": "^11.56.0",
|
|
49
|
+
"@salesforce/vite-plugin-ui-bundle": "^11.56.0",
|
|
50
50
|
"@testing-library/jest-dom": "^6.6.3",
|
|
51
51
|
"@testing-library/react": "^16.1.0",
|
|
52
52
|
"@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,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
|
+
}
|
|
@@ -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
|
+
}
|