@salesforce/ui-bundle-template-feature-react-search 11.10.1 β 11.11.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/README.md +435 -93
- package/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__popover.tsx +40 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
package/README.md
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# @salesforce/ui-bundle-template-feature-react-search
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
Configuration-driven, multi-source search for **React** applications on the
|
|
4
6
|
Salesforce platform.
|
|
5
7
|
|
|
8
|
+
> π§ͺ **Beta.** This feature is in beta. It is usable today, but the API
|
|
9
|
+
> (component props, config schema, and exported primitives) may change in a
|
|
10
|
+
> future release, and breaking changes can land in a minor version. Pin the
|
|
11
|
+
> package version and review the [CHANGELOG](./CHANGELOG.md) before upgrading.
|
|
12
|
+
|
|
6
13
|
> βοΈ **React only.** This package ships React components and hooks built on
|
|
7
14
|
> `react` 19 and `react-router` 7. Use it in a React application; it is not
|
|
8
15
|
> compatible with LWC or other non-React UIs β see [Requirements](#requirements).
|
|
@@ -35,8 +42,8 @@ changes.
|
|
|
35
42
|
- [Creating your own config](#creating-your-own-config)
|
|
36
43
|
- [Source fields](#source-fields-sobjectsourceconfig)
|
|
37
44
|
- [`displayFields` shapes](#displayfields-shapes)
|
|
38
|
-
- [`
|
|
39
|
-
- [`
|
|
45
|
+
- [`filterBy` types](#filterby-types)
|
|
46
|
+
- [`sortBy` and `defaultSort`](#sortby-and-defaultsort)
|
|
40
47
|
- [`routePattern` tokens](#routepattern-tokens)
|
|
41
48
|
- [A complete source, annotated](#a-complete-source-annotated)
|
|
42
49
|
- [Use cases](#use-cases)
|
|
@@ -49,6 +56,8 @@ changes.
|
|
|
49
56
|
- [7. Headless β drive your own UI with `useSearch`](#7-headless--drive-your-own-ui-with-usesearch)
|
|
50
57
|
- [8. Mixed card layouts per source](#8-mixed-card-layouts-per-source)
|
|
51
58
|
- [9. Global pagination across all sources](#9-global-pagination-across-all-sources)
|
|
59
|
+
- [10. Build a custom unified-results UI (grid + pager + filters + count)](#10-build-a-custom-unified-results-ui-grid--pager--filters--count)
|
|
60
|
+
- [11. Choosing a merge order (sequential vs proportional)](#11-choosing-a-merge-order-sequential-vs-proportional)
|
|
52
61
|
- [URL and state conventions](#url-and-state-conventions)
|
|
53
62
|
- [Public API](#public-api)
|
|
54
63
|
- [Common mistakes](#common-mistakes)
|
|
@@ -98,10 +107,14 @@ export default function SearchPage() {
|
|
|
98
107
|
}
|
|
99
108
|
```
|
|
100
109
|
|
|
101
|
-
This renders: a search bar, a
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
This renders: a search bar, a live total-result count, a scope dropdown (when
|
|
111
|
+
the config has 2+ sources), and one results section per source β each with
|
|
112
|
+
auto-rendered rows, a filter sidebar, a sort dropdown, per-source pagination,
|
|
113
|
+
and a per-source Reset button inside its filter panel. (Reset is per-source;
|
|
114
|
+
there is no global reset button.) Set `pagination.mode: "merged"` and the same
|
|
115
|
+
component instead renders one combined grid with a single shared pager
|
|
116
|
+
([Β§9](#9-global-pagination-across-all-sources)). Everything is driven by the
|
|
117
|
+
config you pass in.
|
|
105
118
|
|
|
106
119
|
For a real application you'll typically pass [your own config](#creating-your-own-config).
|
|
107
120
|
|
|
@@ -147,14 +160,26 @@ slots, or drop down to the `useSearch` hook for full control:
|
|
|
147
160
|
|
|
148
161
|
## Creating your own config
|
|
149
162
|
|
|
150
|
-
A `SearchConfig` is
|
|
151
|
-
your own code and pass it to
|
|
163
|
+
A `SearchConfig` is `{ sources: SObjectSourceConfig[] }` plus an optional global
|
|
164
|
+
`pagination` block. Define it in your own code and pass it to
|
|
165
|
+
`<Search config={...} />`:
|
|
152
166
|
|
|
153
167
|
```tsx
|
|
154
168
|
import { Search } from "@salesforce/ui-bundle-template-feature-react-search";
|
|
155
169
|
import type { SearchConfig } from "@salesforce/ui-bundle-template-feature-react-search";
|
|
156
170
|
|
|
157
171
|
const config: SearchConfig = {
|
|
172
|
+
// One global pagination block β the single source of truth for the whole
|
|
173
|
+
// search experience. `mode` / `mergeOrder` / `pageSize` / `pageSizeOptions`
|
|
174
|
+
// apply to every object type and in every scope, including when the dropdown
|
|
175
|
+
// narrows to one source and on single-object pages (restrictTo / lockedScope).
|
|
176
|
+
// Omit to default to per-source mode, page size 10, options [10, 25, 50].
|
|
177
|
+
pagination: {
|
|
178
|
+
mode: "merged", // "per-source" (default) | "merged"
|
|
179
|
+
mergeOrder: "proportional", // "sequential" (default) | "interleaved" | "proportional"
|
|
180
|
+
pageSize: 12,
|
|
181
|
+
pageSizeOptions: [12, 24, 48],
|
|
182
|
+
},
|
|
158
183
|
sources: [
|
|
159
184
|
{
|
|
160
185
|
kind: "sobject",
|
|
@@ -173,6 +198,16 @@ export default function SearchPage() {
|
|
|
173
198
|
}
|
|
174
199
|
```
|
|
175
200
|
|
|
201
|
+
**One pagination config for everything.** There is no per-source pagination
|
|
202
|
+
_config_ β `config.pagination` is the single source of truth. The same
|
|
203
|
+
`pageSize` / `pageSizeOptions` apply across every object type and in every
|
|
204
|
+
scope β the unified `/search` page, when the scope dropdown narrows to one
|
|
205
|
+
source, and single-object pages locked with `restrictTo` / `lockedScope`.
|
|
206
|
+
How that config is _displayed_ depends on `mode`: the default `"per-source"`
|
|
207
|
+
paginates each object's section on its own cursor, while `"merged"` collapses
|
|
208
|
+
in-scope sources into one combined grid whose page order is set by `mergeOrder`
|
|
209
|
+
(see [Β§9](#9-global-pagination-across-all-sources)).
|
|
210
|
+
|
|
176
211
|
You can also keep the config as a JSON file and import it β cast it to
|
|
177
212
|
`SearchConfig` since the structure is not validated at runtime (see the note at
|
|
178
213
|
the end of this section).
|
|
@@ -182,23 +217,37 @@ uiapi GraphQL bridge.
|
|
|
182
217
|
|
|
183
218
|
### Source fields (`SObjectSourceConfig`)
|
|
184
219
|
|
|
185
|
-
| Field | Required | Type | Description
|
|
186
|
-
| ------------------ | -------- | ------------------------- |
|
|
187
|
-
| `kind` | β
| `"sobject"` | Discriminator selecting the runtime adapter.
|
|
188
|
-
| `key` | β
| `string` | Stable id β used as the GraphQL alias, URL namespace, and result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`.
|
|
189
|
-
| `objectName` | β
| `string` | GraphQL object name (`"Account"`, `"Contact"`, β¦).
|
|
190
|
-
| `label` | β
| `string` |
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
|
|
220
|
+
| Field | Required | Type | Description |
|
|
221
|
+
| ------------------ | -------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
222
|
+
| `kind` | β
| `"sobject"` | Discriminator selecting the runtime adapter. |
|
|
223
|
+
| `key` | β
| `string` | Stable id β used as the GraphQL alias, URL namespace, and result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. |
|
|
224
|
+
| `objectName` | β
| `string` | GraphQL object name (`"Account"`, `"Contact"`, β¦). |
|
|
225
|
+
| `label` | β
| `string` | Plural display label β section header, scope dropdown, empty-state messages (`"Accounts"`). |
|
|
226
|
+
| `labelSingular` | | `string` | Singular label for one record (`"Account"`) β used for the merged-grid source badge. Falls back to `label`. |
|
|
227
|
+
| `searchableFields` | β
| `string[]` | Fields the global `q` term matches. OR-ed as `like %q%`. Supports dot-paths (`"Owner.Name"`). |
|
|
228
|
+
| `displayFields` | β
| `DisplayField[]` | Fields selected in the GraphQL query and used by the default row layout. (See below.) |
|
|
229
|
+
| `routePattern` | | `string` | Makes the default row a `<Link>`. Tokens like `:id` / `:Name` are substituted per record. Your app must register a matching route to render the target ([details](#routepattern-tokens)). Omit for non-clickable rows. |
|
|
230
|
+
| `idField` | | `string` (default `"Id"`) | Unique-id field. Drives the `:id` route token. Always selected automatically β **do not** list it in `displayFields`. |
|
|
231
|
+
| `filterBy` | | `FilterFieldConfig[]` | Structured filters for the source. Today each renders an input in the per-source sidebar. |
|
|
232
|
+
| `sortBy` | | `SortFieldConfig[]` | Options in the per-source sort dropdown. |
|
|
233
|
+
| `defaultSort` | | `{ field, direction }` | Initial sort applied when the URL has none. `direction` is `"ASC"` or `"DESC"`. |
|
|
234
|
+
| `whereTypeName` | | `string` | GraphQL `where` variable type. Defaults to `${objectName}_Filter`. Override only for non-conventional schemas. |
|
|
235
|
+
| `orderByTypeName` | | `string` | GraphQL `orderBy` variable type. Defaults to `${objectName}_OrderBy`. |
|
|
236
|
+
|
|
237
|
+
### Global pagination (`SearchConfig.pagination`)
|
|
238
|
+
|
|
239
|
+
Optional. One block governs pagination for the whole search experience β
|
|
240
|
+
sources carry no pagination config of their own. Omit it to default to
|
|
241
|
+
per-source mode, page size `10`, options `[10, 25, 50]`. (Note: `mode`
|
|
242
|
+
`"per-source"` still paginates each object's section independently β see the
|
|
243
|
+
`mode` row below and [Β§9](#9-global-pagination-across-all-sources).)
|
|
244
|
+
|
|
245
|
+
| Field | Required | Type | Description |
|
|
246
|
+
| ----------------- | -------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
247
|
+
| `pageSize` | β
| `number` | Items per page (across all sources in `"merged"` mode; per source otherwise). |
|
|
248
|
+
| `pageSizeOptions` | β
| `number[]` | Page sizes offered in the "results per page" control. |
|
|
249
|
+
| `mode` | | `"per-source"` \| `"merged"` (default `per-source`) | `"merged"` windows the cumulative result set into fixed pages of `pageSize`; `"per-source"` advances each source's own cursor. See [Β§9](#9-global-pagination-across-all-sources). |
|
|
250
|
+
| `mergeOrder` | | `"sequential"` \| `"interleaved"` \| `"proportional"` (default `sequential`) | In `"merged"` mode, how nodes from different sources are ordered. Ignored in `"per-source"` mode. See [Β§9](#9-global-pagination-across-all-sources). |
|
|
202
251
|
|
|
203
252
|
### `displayFields` shapes
|
|
204
253
|
|
|
@@ -213,43 +262,52 @@ layout (first entry β row title; the rest β subtitle, joined with `Β·`):
|
|
|
213
262
|
|
|
214
263
|
> The `idField` (default `"Id"`) is always emitted β never list it explicitly.
|
|
215
264
|
|
|
216
|
-
### `
|
|
265
|
+
### `filterBy` types
|
|
217
266
|
|
|
218
|
-
Each `FilterFieldConfig` is `{ field, label, type, options?, placeholder?, helpText? }`.
|
|
267
|
+
Each `FilterFieldConfig` is `{ field, label, type, options?, placeholder?, dateMode?, helpText? }`.
|
|
219
268
|
The `type` drives which input renders and how the `where` clause is built:
|
|
220
269
|
|
|
221
|
-
| `type` | UI
|
|
222
|
-
| --------------- |
|
|
223
|
-
| `text` | Text input
|
|
224
|
-
| `picklist` | Single-select dropdown
|
|
225
|
-
| `multipicklist` | Multi-select
|
|
226
|
-
| `numeric` | min / max number inputs
|
|
227
|
-
| `boolean` | Tri-state (any/true/false)
|
|
228
|
-
| `date` |
|
|
229
|
-
| `daterange` |
|
|
230
|
-
| `datetime` |
|
|
231
|
-
| `datetimerange` |
|
|
270
|
+
| `type` | UI | `where` clause |
|
|
271
|
+
| --------------- | ---------------------------------- | -------------------------------------------- |
|
|
272
|
+
| `text` | Text input | `{ field: { like: "%value%" } }` |
|
|
273
|
+
| `picklist` | Single-select dropdown | `{ field: { eq: value } }` |
|
|
274
|
+
| `multipicklist` | Multi-select dropdown (checkboxes) | `{ field: { in: [...] } }` (or `eq` for one) |
|
|
275
|
+
| `numeric` | min / max number inputs | `{ field: { gte, lte } }` |
|
|
276
|
+
| `boolean` | Tri-state (any/true/false) | `{ field: { eq: true \| false } }` |
|
|
277
|
+
| `date` | See `dateMode` | `{ field: { gte \| lte: { value } } }` |
|
|
278
|
+
| `daterange` | See `dateMode` | `and` of `gte` / `lte` |
|
|
279
|
+
| `datetime` | See `dateMode` | start/end-of-day ISO bounds |
|
|
280
|
+
| `datetimerange` | See `dateMode` | `and` of start/end-of-day ISO bounds |
|
|
232
281
|
|
|
233
282
|
For `picklist` / `multipicklist`, options are **auto-fetched** from the GraphQL
|
|
234
283
|
aggregate API (`groupBy`) on first render. Skip the fetch by supplying inline
|
|
235
284
|
`options: [{ value, label }]` β required for fields that aren't group-by-able
|
|
236
285
|
(formulas, long text, etc.).
|
|
237
286
|
|
|
238
|
-
|
|
287
|
+
For the date-family types, `dateMode` selects the comparison chrome (defaults to
|
|
288
|
+
`"comparison"`):
|
|
289
|
+
|
|
290
|
+
| `dateMode` | UI |
|
|
291
|
+
| -------------- | ------------------------------------------------------------------- |
|
|
292
|
+
| `"comparison"` | An **After** / **Before** selector with one date input (default). |
|
|
293
|
+
| `"range"` | A single **Between** control β two date inputs (lowerβupper bound). |
|
|
294
|
+
| `"both"` | A **Between** / **After** / **Before** selector spanning the two. |
|
|
239
295
|
|
|
240
|
-
`
|
|
296
|
+
### `sortBy` and `defaultSort`
|
|
297
|
+
|
|
298
|
+
`sortBy` is a list of `{ field, label }`. Each becomes an option in the
|
|
241
299
|
per-source sort dropdown (with an ASC/DESC toggle). `defaultSort` sets the
|
|
242
300
|
initial order when no sort is present in the URL:
|
|
243
301
|
|
|
244
302
|
```jsonc
|
|
245
|
-
"
|
|
303
|
+
"sortBy": [
|
|
246
304
|
{ "field": "CloseDate", "label": "Close Date" },
|
|
247
305
|
{ "field": "Amount", "label": "Amount" }
|
|
248
306
|
],
|
|
249
307
|
"defaultSort": { "field": "CloseDate", "direction": "DESC" }
|
|
250
308
|
```
|
|
251
309
|
|
|
252
|
-
Omit `
|
|
310
|
+
Omit `sortBy` entirely to hide the sort dropdown for that source.
|
|
253
311
|
|
|
254
312
|
### `routePattern` tokens
|
|
255
313
|
|
|
@@ -262,6 +320,20 @@ If **any** token resolves to `null` for a record, that row falls back to a
|
|
|
262
320
|
plain, non-clickable layout. Make sure tokenized fields are present in
|
|
263
321
|
`displayFields`.
|
|
264
322
|
|
|
323
|
+
> **`routePattern` only builds the link β you must supply the destination.**
|
|
324
|
+
> This feature turns a result row into a `<Link to="/accounts/<id>">`; it does
|
|
325
|
+
> **not** create the page that link points at. For `"/accounts/:id"` to actually
|
|
326
|
+
> show an account, your app must register a matching route (e.g.
|
|
327
|
+
> `<Route path="/accounts/:id" element={<AccountDetail />} />`) and implement the
|
|
328
|
+
> `AccountDetail` page/component that reads the `:id` param and renders that
|
|
329
|
+
> object's details. Without a corresponding route the link resolves to a blank or
|
|
330
|
+
> not-found page. Point `routePattern` at routes you already have (or add them
|
|
331
|
+
> alongside), and give every searchable source its own detail route. If you don't
|
|
332
|
+
> want clickable rows β for example when details open in a modal instead of on a
|
|
333
|
+
> route β omit `routePattern` entirely (this is what the `propertymanagementapp`
|
|
334
|
+
> template does: it has no `routePattern` and opens a detail modal from a custom
|
|
335
|
+
> `renderResult` instead).
|
|
336
|
+
|
|
265
337
|
### A complete source, annotated
|
|
266
338
|
|
|
267
339
|
```jsonc
|
|
@@ -269,7 +341,8 @@ plain, non-clickable layout. Make sure tokenized fields are present in
|
|
|
269
341
|
"kind": "sobject", // discriminator
|
|
270
342
|
"key": "leads", // GraphQL alias + URL namespace + result key
|
|
271
343
|
"objectName": "Lead", // GraphQL type
|
|
272
|
-
"label": "Leads", // section header + scope option
|
|
344
|
+
"label": "Leads", // plural β section header + scope option
|
|
345
|
+
"labelSingular": "Lead", // singular β merged-grid source badge (optional; falls back to label)
|
|
273
346
|
"routePattern": "/leads/:id", // default row β <Link to="/leads/<id>">
|
|
274
347
|
"idField": "Id", // default; drives :id (don't add to displayFields)
|
|
275
348
|
"searchableFields": ["Name", "Company", "Email"], // global q is OR-ed across these
|
|
@@ -280,23 +353,22 @@ plain, non-clickable layout. Make sure tokenized fields are present in
|
|
|
280
353
|
"Email", // β
|
|
281
354
|
{ "name": "Owner", "subfields": ["Name"] }, // relationship traversal
|
|
282
355
|
],
|
|
283
|
-
"
|
|
356
|
+
"filterBy": [
|
|
284
357
|
{ "field": "Status", "label": "Status", "type": "picklist" },
|
|
285
358
|
{ "field": "Industry", "label": "Industry", "type": "picklist" },
|
|
286
359
|
],
|
|
287
|
-
"
|
|
360
|
+
"sortBy": [
|
|
288
361
|
{ "field": "Name", "label": "Name" },
|
|
289
362
|
{ "field": "CreatedDate", "label": "Created Date" },
|
|
290
363
|
],
|
|
291
364
|
"defaultSort": { "field": "CreatedDate", "direction": "DESC" },
|
|
292
|
-
"pageSize": 10,
|
|
293
|
-
"validPageSizes": [5, 10, 20],
|
|
294
365
|
}
|
|
295
366
|
```
|
|
296
367
|
|
|
297
368
|
That single entry gives Leads a full search experience: a global-term match
|
|
298
369
|
across name/company/email, two auto-populated picklist filters, a sort
|
|
299
|
-
dropdown, pagination
|
|
370
|
+
dropdown, pagination (governed by the top-level `pagination` block), and
|
|
371
|
+
clickable result rows linking to `/leads/<id>`.
|
|
300
372
|
|
|
301
373
|
> **No runtime schema validation.** A config object is trusted as-is; a typo
|
|
302
374
|
> surfaces at GraphQL query time, not when the config loads. Layer your own
|
|
@@ -491,13 +563,45 @@ same pattern) and wrap each source's `result.nodes` in your own grid container.
|
|
|
491
563
|
|
|
492
564
|
### 9. Global pagination across all sources
|
|
493
565
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
566
|
+
By default the drop-in `<Search>` paginates each source independently. Set
|
|
567
|
+
`config.pagination.mode: "merged"` and `<Search>` switches to a single combined
|
|
568
|
+
grid with one shared pager over the **cumulative** result set automatically β no
|
|
569
|
+
prop needed (that's exactly what [Β§10](#10-build-a-custom-unified-results-ui-grid--pager--filters--count)
|
|
570
|
+
assembles by hand for a fully custom UI). Either way, the merged behaviour is
|
|
571
|
+
built from three aggregate accessors `useSearch` derives from the in-scope
|
|
572
|
+
sources:
|
|
573
|
+
|
|
574
|
+
| Accessor | Use |
|
|
575
|
+
| ------------------------- | ----------------------------------------------------------------------------- |
|
|
576
|
+
| `handle.inScopeSources` | The `SourceController[]` participating in the current scope (config order). |
|
|
577
|
+
| `handle.mergedResults` | In-scope nodes flattened into one `{ sourceKey, node }[]` for a single grid. |
|
|
578
|
+
| `handle.globalPagination` | Aggregate `pageIndex` / `pageCount` / `pageSize` / `hasNextβ¦` + page actions. |
|
|
579
|
+
|
|
580
|
+
**Pick a pagination mode** via `config.pagination.mode`:
|
|
581
|
+
|
|
582
|
+
| Mode | `mergedResults` | Page size means⦠| `goToPage` |
|
|
583
|
+
| -------------- | ------------------------------------ | --------------------------------- | ---------------- |
|
|
584
|
+
| `"merged"` | exactly the current page's window | items **per page** (e.g. 6 total) | jump to any page |
|
|
585
|
+
| `"per-source"` | every in-scope source's current page | items **per source** (6 Γ N) | adjacent only |
|
|
586
|
+
|
|
587
|
+
Use `"merged"` when you want a true combined list β "6 per page" shows exactly 6
|
|
588
|
+
cards and `pageCount = ceil(totalCount / pageSize)`. The default `"per-source"`
|
|
589
|
+
keeps each source on its own cursor (cheaper for deep lists, but a page can hold
|
|
590
|
+
up to `sources Γ pageSize` items).
|
|
591
|
+
|
|
592
|
+
**Order sources** with `config.pagination.mergeOrder`:
|
|
593
|
+
|
|
594
|
+
| Order | `mergedResults` layout |
|
|
595
|
+
| ------------------------ | ----------------------------------------------------------------------- |
|
|
596
|
+
| `"sequential"` (default) | all of source 1, then source 2, β¦ β the first source leads. |
|
|
597
|
+
| `"interleaved"` | round-robin, one per source per round β every source ranks **equally**. |
|
|
598
|
+
| `"proportional"` | each source spread evenly by its result count β share **matches size**. |
|
|
599
|
+
|
|
600
|
+
Pass `"interleaved"` to give every object type equal footing regardless of how
|
|
601
|
+
many results it has; pass `"proportional"` to weight types by their result count
|
|
602
|
+
(a source with 100 hits appears ~10Γ as often as one with 10, so each page
|
|
603
|
+
mirrors the overall mix). Proportional reads each source's `totalCount`. Page
|
|
604
|
+
boundaries stay exact under all three orders.
|
|
501
605
|
|
|
502
606
|
```tsx
|
|
503
607
|
import {
|
|
@@ -508,48 +612,45 @@ import {
|
|
|
508
612
|
} from "@salesforce/ui-bundle-template-feature-react-search";
|
|
509
613
|
|
|
510
614
|
function GloballyPaginatedSearch() {
|
|
615
|
+
// mode + mergeOrder live in config.pagination (e.g. { mode: "merged",
|
|
616
|
+
// mergeOrder: "interleaved", pageSize: 6, pageSizeOptions: [6, 12, 24] }).
|
|
511
617
|
const handle = useSearch(config);
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
// Aggregate paging state: there's a next page if ANY source has one;
|
|
515
|
-
// you can go back only once every source is past its first page.
|
|
516
|
-
const hasNextPage = controllers.some((c) => c.pagination.hasNextPage);
|
|
517
|
-
const hasPreviousPage =
|
|
518
|
-
controllers.length > 0 && controllers.every((c) => c.pagination.hasPreviousPage);
|
|
519
|
-
const pageSize = controllers[0]?.pagination.pageSize ?? 10;
|
|
520
|
-
const pageIndex = controllers[0]?.pagination.pageIndex ?? 0;
|
|
618
|
+
const { mergedResults, globalPagination: page } = handle;
|
|
521
619
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
620
|
+
// One combined, cumulatively-paginated grid. Each item knows its source, so
|
|
621
|
+
// you can pick the right card/row per node.
|
|
622
|
+
const sourceByKey = Object.fromEntries(
|
|
623
|
+
handle.inScopeSources.map((c) => [c.config.key, c.config]),
|
|
624
|
+
);
|
|
525
625
|
|
|
526
626
|
return (
|
|
527
627
|
<div className="max-w-4xl mx-auto py-6 space-y-6">
|
|
528
628
|
<SearchBar value={handle.q} onChange={handle.setQ} placeholder="Searchβ¦" />
|
|
529
629
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
{/* One control drives every source. */}
|
|
630
|
+
<p className="text-sm text-muted-foreground">
|
|
631
|
+
{page.totalCount} results Β· Page {page.pageIndex + 1} of {page.pageCount}
|
|
632
|
+
</p>
|
|
633
|
+
|
|
634
|
+
<ul className="divide-y">
|
|
635
|
+
{mergedResults.map(({ sourceKey, node }, i) => (
|
|
636
|
+
<li key={i} className="py-2">
|
|
637
|
+
<DefaultResultRow node={node} source={sourceByKey[sourceKey]} />
|
|
638
|
+
</li>
|
|
639
|
+
))}
|
|
640
|
+
</ul>
|
|
641
|
+
|
|
642
|
+
{/* Passing pageCount + onGoToPage renders numbered page buttons. */}
|
|
544
643
|
<PaginationControls
|
|
545
|
-
pageIndex={pageIndex}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
644
|
+
pageIndex={page.pageIndex}
|
|
645
|
+
pageCount={page.pageCount}
|
|
646
|
+
hasNextPage={page.hasNextPage}
|
|
647
|
+
hasPreviousPage={page.hasPreviousPage}
|
|
648
|
+
pageSize={page.pageSize}
|
|
649
|
+
pageSizeOptions={page.pageSizeOptions}
|
|
650
|
+
onNextPage={page.goToNextPage}
|
|
651
|
+
onPreviousPage={page.goToPreviousPage}
|
|
652
|
+
onGoToPage={page.goToPage}
|
|
653
|
+
onPageSizeChange={page.setPageSize}
|
|
553
654
|
disabled={handle.loading}
|
|
554
655
|
/>
|
|
555
656
|
</div>
|
|
@@ -557,10 +658,246 @@ function GloballyPaginatedSearch() {
|
|
|
557
658
|
}
|
|
558
659
|
```
|
|
559
660
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
661
|
+
These accessors track scope automatically: in `"all"` scope they span every
|
|
662
|
+
source; under a single scope (or `lockedScope`) they collapse to just that one,
|
|
663
|
+
so the same layout serves both the unified page and per-object pages.
|
|
664
|
+
|
|
665
|
+
See [Β§10](#10-build-a-custom-unified-results-ui-grid--pager--filters--count) for
|
|
666
|
+
a complete custom UI β merged grid, pagination widget, per-source filter panels,
|
|
667
|
+
and a result count β assembled from these accessors and the exported primitives.
|
|
668
|
+
|
|
669
|
+
> **How `"merged"` is exact.** For global page `P`, every in-scope source fetches
|
|
670
|
+
> `(P + 1) Γ pageSize` rows from its front; concatenating them in config order
|
|
671
|
+
> and slicing `[P Γ pageSize, (P + 1) Γ pageSize)` yields the precise page,
|
|
672
|
+
> because the first `(P + 1) Γ pageSize` items of the concatenation are always
|
|
673
|
+
> the true cumulative prefix. Cost grows with page depth (each Next refetches a
|
|
674
|
+
> larger window), so it suits a combined grid, not an infinite feed.
|
|
675
|
+
>
|
|
676
|
+
> **`"per-source"` model.** Sources paginate with independent cursors β no global
|
|
677
|
+
> offset β so the "global page" is the furthest-advanced in-scope source's page.
|
|
678
|
+
> `goToNextPage` advances only sources that still have data; a source that runs
|
|
679
|
+
> out is **left behind** and drops out of `mergedResults` on later pages (its
|
|
680
|
+
> final page was already shown). `pageCount` is the max of
|
|
681
|
+
> `ceil(totalCount / pageSize)` across in-scope sources, and `goToPage` honours
|
|
682
|
+
> only adjacent pages. For fully independent pagers, render one `<SourceSection>`
|
|
683
|
+
> (or `PaginationControls`) per `handle.sources[key]` and skip the aggregate API.
|
|
684
|
+
|
|
685
|
+
### 10. Build a custom unified-results UI (grid + pager + filters + count)
|
|
686
|
+
|
|
687
|
+
For a combined **card grid** with one **shared** pager, a **total-results
|
|
688
|
+
count**, and **per-source filter panels**, the fastest path is
|
|
689
|
+
`config.pagination.mode: "merged"` on the drop-in `<Search>` β it renders all of
|
|
690
|
+
this ([Β§9](#9-global-pagination-across-all-sources)). Drop to the headless
|
|
691
|
+
recipe below only when you need control the drop-in doesn't expose (a bespoke
|
|
692
|
+
grid container, custom chrome placement, extra surrounding UI): assemble it
|
|
693
|
+
yourself from `useSearch` plus the exported primitives. This is what the
|
|
694
|
+
`propertymanagementapp` template's `UnifiedSearch` component used to hand-roll
|
|
695
|
+
before adopting merged mode; the skeleton below is the same recipe, trimmed to
|
|
696
|
+
the essentials.
|
|
697
|
+
|
|
698
|
+
The pieces you compose:
|
|
699
|
+
|
|
700
|
+
| Piece | What renders it | Source of truth |
|
|
701
|
+
| --------------------- | --------------------------------------------------------------- | ------------------------------------- |
|
|
702
|
+
| Search box | `<SearchBar value={handle.q} onChange={handle.setQ} />` | `handle.q` / `handle.setQ` |
|
|
703
|
+
| Scope selector | `<ScopeSelector config scope onScopeChange />` | `handle.scope` / `handle.setScope` |
|
|
704
|
+
| **Result count** | your own `<p>` reading `page.totalCount` | `handle.globalPagination.totalCount` |
|
|
705
|
+
| **Merged grid** | map over `handle.mergedResults` β your card per `sourceKey` | `handle.mergedResults` |
|
|
706
|
+
| **Pagination widget** | `<PaginationControls β¦{page} />` | `handle.globalPagination` |
|
|
707
|
+
| **Filter panel** | `<FilterProvider>` + `<DefaultFilterPanel source>` (per source) | `handle.inScopeSources[i]` controller |
|
|
708
|
+
|
|
709
|
+
Two rules make the layout work in **every** scope β the unified "All" view, a
|
|
710
|
+
single source picked in the selector, and a `lockedScope` page β from one
|
|
711
|
+
implementation:
|
|
712
|
+
|
|
713
|
+
1. **Render the count on its own row, directly above the grid, unconditionally.**
|
|
714
|
+
`page.totalCount` is the cumulative in-scope total, so it's meaningful whether
|
|
715
|
+
one source or all of them are in scope. Don't gate it on scope.
|
|
716
|
+
2. **Show per-source filter chrome only when a single source is in scope.** Under
|
|
717
|
+
"All", heterogeneous per-source filters can't be combined into one panel, so
|
|
718
|
+
collapse to just the count. Detect this with `handle.scope !== ALL_SCOPE`
|
|
719
|
+
(then `handle.inScopeSources[0]` is the sole controller).
|
|
720
|
+
|
|
721
|
+
```tsx
|
|
722
|
+
import {
|
|
723
|
+
useSearch,
|
|
724
|
+
SearchBar,
|
|
725
|
+
ScopeSelector,
|
|
726
|
+
SortControl,
|
|
727
|
+
ActiveFilters,
|
|
728
|
+
FilterProvider,
|
|
729
|
+
FilterResetButton,
|
|
730
|
+
DefaultFilterPanel,
|
|
731
|
+
PaginationControls,
|
|
732
|
+
ALL_SCOPE,
|
|
733
|
+
} from "@salesforce/ui-bundle-template-feature-react-search";
|
|
734
|
+
|
|
735
|
+
function UnifiedSearch({ restrictTo }: { restrictTo?: string }) {
|
|
736
|
+
// config.pagination = { mode: "merged", mergeOrder: "proportional",
|
|
737
|
+
// pageSize: 6, pageSizeOptions: [6, 12, 24] } β see Β§9 and Β§11.
|
|
738
|
+
const handle = useSearch(config, { lockedScope: restrictTo });
|
|
739
|
+
const { globalPagination: page, mergedResults } = handle;
|
|
740
|
+
|
|
741
|
+
const singleSourceInScope = handle.scope !== ALL_SCOPE;
|
|
742
|
+
const soleController = singleSourceInScope ? handle.inScopeSources[0] : undefined;
|
|
743
|
+
|
|
744
|
+
return (
|
|
745
|
+
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
|
746
|
+
{/* Row 1 β search box + scope selector (hidden when the scope is locked). */}
|
|
747
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
748
|
+
<SearchBar value={handle.q} onChange={handle.setQ} placeholder="Searchβ¦" />
|
|
749
|
+
{!handle.scopeLocked && (
|
|
750
|
+
<ScopeSelector config={config} scope={handle.scope} onScopeChange={handle.setScope} />
|
|
751
|
+
)}
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
{/* Row 2 β the sole source's filters + sort, only under a single scope. */}
|
|
755
|
+
{singleSourceInScope && soleController && (
|
|
756
|
+
<FilterProvider
|
|
757
|
+
filters={soleController.filters.active}
|
|
758
|
+
onFilterChange={soleController.filters.set}
|
|
759
|
+
onFilterRemove={soleController.filters.remove}
|
|
760
|
+
onReset={() =>
|
|
761
|
+
soleController.filters.active.forEach((f) => soleController.filters.remove(f.field))
|
|
762
|
+
}
|
|
763
|
+
>
|
|
764
|
+
<div className="rounded-md border p-3 space-y-3">
|
|
765
|
+
<div className="flex items-center justify-between">
|
|
766
|
+
<h3 className="text-sm font-semibold">Filters</h3>
|
|
767
|
+
<FilterResetButton size="sm" />
|
|
768
|
+
</div>
|
|
769
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
770
|
+
<DefaultFilterPanel source={soleController.config} />
|
|
771
|
+
</div>
|
|
772
|
+
{soleController.config.sortBy && (
|
|
773
|
+
<SortControl
|
|
774
|
+
configs={soleController.config.sortBy}
|
|
775
|
+
sort={soleController.sort.current}
|
|
776
|
+
onSortChange={soleController.sort.set}
|
|
777
|
+
/>
|
|
778
|
+
)}
|
|
779
|
+
<ActiveFilters
|
|
780
|
+
filters={soleController.filters.active}
|
|
781
|
+
onRemove={soleController.filters.remove}
|
|
782
|
+
/>
|
|
783
|
+
</div>
|
|
784
|
+
</FilterProvider>
|
|
785
|
+
)}
|
|
786
|
+
|
|
787
|
+
{/* Count β its own row, directly above the grid, in EVERY scope. */}
|
|
788
|
+
{!handle.loading && !handle.error && (
|
|
789
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
790
|
+
{page.totalCount} total result{page.totalCount === 1 ? "" : "s"}
|
|
791
|
+
</p>
|
|
792
|
+
)}
|
|
793
|
+
|
|
794
|
+
{/* Merged, cumulatively-paginated card grid. Each item knows its source. */}
|
|
795
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
796
|
+
{mergedResults.map(({ sourceKey, node }, i) => (
|
|
797
|
+
<MyCard key={i} sourceKey={sourceKey} node={node} />
|
|
798
|
+
))}
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
{/* One shared pager over the cumulative set. Passing pageCount + onGoToPage
|
|
802
|
+
renders numbered page buttons; omit them for prev/next only. */}
|
|
803
|
+
{page.totalCount > 0 && (
|
|
804
|
+
<PaginationControls
|
|
805
|
+
pageIndex={page.pageIndex}
|
|
806
|
+
pageCount={page.pageCount}
|
|
807
|
+
hasNextPage={page.hasNextPage}
|
|
808
|
+
hasPreviousPage={page.hasPreviousPage}
|
|
809
|
+
pageSize={page.pageSize}
|
|
810
|
+
pageSizeOptions={page.pageSizeOptions}
|
|
811
|
+
onNextPage={page.goToNextPage}
|
|
812
|
+
onPreviousPage={page.goToPreviousPage}
|
|
813
|
+
onGoToPage={page.goToPage}
|
|
814
|
+
onPageSizeChange={page.setPageSize}
|
|
815
|
+
disabled={handle.loading || !!handle.error}
|
|
816
|
+
/>
|
|
817
|
+
)}
|
|
818
|
+
</div>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
Notes:
|
|
824
|
+
|
|
825
|
+
- **`MyCard`** switches on `sourceKey` to pick the right card component per
|
|
826
|
+
object type (see [Β§8](#8-mixed-card-layouts-per-source)). Read node fields with
|
|
827
|
+
the exported `fieldValue()`, or render a `<DefaultResultRow node source>` for a
|
|
828
|
+
built-in row.
|
|
829
|
+
- **Filter primitives are wired through `FilterProvider`**, not props β anything
|
|
830
|
+
inside it (`DefaultFilterPanel`, or individual `TextFilter` / `SelectFilter`
|
|
831
|
+
from [Β§6](#6-replace-a-sources-filter-sidebar)) reads and writes that source's
|
|
832
|
+
filter state automatically.
|
|
833
|
+
- **The pager needs `mode: "merged"`** in `config.pagination` for the "shows
|
|
834
|
+
exactly `pageSize` cards, `goToPage` jumps anywhere" behavior above. Under the
|
|
835
|
+
default `"per-source"` mode the same widget still works but paginates each
|
|
836
|
+
source on its own cursor (see the Β§9 mode table).
|
|
837
|
+
- **This same component serves single-object pages** β pass `restrictTo` (a
|
|
838
|
+
source key) as `lockedScope`; `scopeLocked` becomes `true`, the selector hides,
|
|
839
|
+
and `mergedResults` / `page` collapse to that one source. The count and pager
|
|
840
|
+
code paths are unchanged.
|
|
841
|
+
|
|
842
|
+
### 11. Choosing a merge order (sequential vs proportional)
|
|
843
|
+
|
|
844
|
+
`config.pagination.mergeOrder` decides how `"merged"`-mode results from
|
|
845
|
+
different sources are ordered within each page. It only matters when 2+ sources
|
|
846
|
+
are in scope; it's ignored in `"per-source"` mode and under a single scope.
|
|
847
|
+
|
|
848
|
+
| Order | Behavior | Reach for it when⦠|
|
|
849
|
+
| ------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
850
|
+
| `"sequential"` (default) | All of source 1, then all of source 2, β¦ in config order. | One source is primary and the rest are secondary β e.g. Accounts first, then supporting Contacts/Opportunities. |
|
|
851
|
+
| `"interleaved"` | Round-robin, one per source per round β every source ranks **equally**. | You want a fair sample of each type up front regardless of how many hits each has (e.g. a "spotlight" grid). |
|
|
852
|
+
| `"proportional"` | Each source spread across the list in proportion to its result count. | You want each page to mirror the overall mix β larger result sets appear more often, matching their share. |
|
|
853
|
+
|
|
854
|
+
**Sequential β front-loads by config order.** The first source fills the early
|
|
855
|
+
pages; later sources only appear once the earlier ones are exhausted. Best when
|
|
856
|
+
there's a natural priority. With `pageSize: 6` and Accounts (100 hits) before
|
|
857
|
+
Contacts (20), page 1 is 6 Accounts, and Contacts don't surface until ~page 17.
|
|
858
|
+
|
|
859
|
+
```tsx
|
|
860
|
+
const config: SearchConfig = {
|
|
861
|
+
pagination: {
|
|
862
|
+
mode: "merged",
|
|
863
|
+
mergeOrder: "sequential",
|
|
864
|
+
pageSize: 6,
|
|
865
|
+
pageSizeOptions: [6, 12, 24],
|
|
866
|
+
},
|
|
867
|
+
sources: [
|
|
868
|
+
{ kind: "sobject", key: "accounts" /* β¦ */ }, // leads
|
|
869
|
+
{ kind: "sobject", key: "contacts" /* β¦ */ }, // follows
|
|
870
|
+
{ kind: "sobject", key: "opportunities" /* β¦ */ }, // last
|
|
871
|
+
],
|
|
872
|
+
};
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Proportional β every page reflects the mix.** Each source is spread evenly by
|
|
876
|
+
its `totalCount`, so a source with 100 hits appears ~5Γ as often as one with 20.
|
|
877
|
+
With the same 100 Accounts / 20 Contacts and `pageSize: 6`, roughly every page is
|
|
878
|
+
5 Accounts + 1 Contact β the 5:1 ratio of the whole result set β so a user sees
|
|
879
|
+
all types from page 1 without one type monopolizing the front.
|
|
880
|
+
|
|
881
|
+
```tsx
|
|
882
|
+
const config: SearchConfig = {
|
|
883
|
+
pagination: {
|
|
884
|
+
mode: "merged",
|
|
885
|
+
mergeOrder: "proportional",
|
|
886
|
+
pageSize: 6,
|
|
887
|
+
pageSizeOptions: [6, 12, 24],
|
|
888
|
+
},
|
|
889
|
+
sources: [
|
|
890
|
+
{ kind: "sobject", key: "accounts" /* β¦ */ },
|
|
891
|
+
{ kind: "sobject", key: "contacts" /* β¦ */ },
|
|
892
|
+
{ kind: "sobject", key: "opportunities" /* β¦ */ },
|
|
893
|
+
],
|
|
894
|
+
};
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
> Proportional reads each source's `totalCount` to compute its share, so a source
|
|
898
|
+
> that omits `totalCount` falls back to its fetched node count for weighting. Page
|
|
899
|
+
> boundaries stay exact under all three orders β only the **order within** the
|
|
900
|
+
> cumulative list changes, never which items belong to page `P`.
|
|
564
901
|
|
|
565
902
|
---
|
|
566
903
|
|
|
@@ -683,6 +1020,11 @@ import type {
|
|
|
683
1020
|
it again duplicates the selection.
|
|
684
1021
|
- **`routePattern` token not in `displayFields`** β the token can't resolve and
|
|
685
1022
|
every row silently falls back to a non-clickable layout.
|
|
1023
|
+
- **`routePattern` without a matching route** β the feature only builds the
|
|
1024
|
+
`<Link>`; it doesn't create the destination. If nothing in your app routes
|
|
1025
|
+
`"/accounts/:id"` to a detail page, the link lands on a blank/not-found view.
|
|
1026
|
+
Register the route + page yourself (or omit `routePattern` and render details
|
|
1027
|
+
another way, e.g. a modal).
|
|
686
1028
|
- **Non-conforming `key`** β must match `/^[A-Za-z_][A-Za-z0-9_]*$/`; it becomes
|
|
687
1029
|
a GraphQL alias and variable prefix. Invalid keys produce malformed documents.
|
|
688
1030
|
- **Picklist auto-fetch failing** β the aggregate `groupBy` requires a
|