@salesforce/ui-bundle-template-feature-react-search
v11.24.4
Published
[Beta] Configuration-driven multi-source search for UI Bundles
Maintainers
Keywords
Readme
@salesforce/ui-bundle-template-feature-react-search
Configuration-driven, multi-source search for React applications on the Salesforce platform.
🧪 Beta. This feature is in beta. It is usable today, but the API (component props, config schema, and exported primitives) may change in a future release, and breaking changes can land in a minor version. Pin the package version and review the CHANGELOG before upgrading.
⚛️ React only. This package ships React components and hooks built on
react19 andreact-router7. Use it in a React application; it is not compatible with LWC or other non-React UIs — see Requirements.
You describe what is searchable in a single config object; the package owns how it's searched — GraphQL query construction, filter/sort/pagination state, URL sync, default rendering, and the orchestration UI. Most apps drop in one component and get a complete search page with zero custom code:
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";
export default function SearchPage() {
return <Search config={config} />;
}Adding a new searchable object is a single entry in your config — no code changes.
Table of contents
- Requirements
- Installation
- Quick start
- The
<Search>component - Creating your own config
- Use cases
- 1. Unified search across many objects
- 2. Single-object search (
restrictTo) - 3. Hand off a term from another page
- 4. Custom row layout for one source
- 5. Hide a source from the results
- 6. Replace a source's filter sidebar
- 7. Headless — drive your own UI with
useSearch - 8. Mixed card layouts per source
- 9. Global pagination across all sources
- 10. Build a custom unified-results UI (grid + pager + filters + count)
- 11. Choosing a merge order (sequential vs proportional)
- URL and state conventions
- Public API
- Common mistakes
Requirements
This is a React-only package. Your application must provide:
- React 19 and react-router 7 — the components render React elements and use react-router for navigation, URL sync, and result links.
@salesforce/platform-sdk— data is fetched viacreateDataSDK().graphql.query()against the Salesforce uiapi GraphQL bridge. Every source today is an SObject queried through this bridge.- lucide-react — used for the control icons.
Your app must be wrapped in a react-router router (e.g. a <BrowserRouter> or a
data router), since the search components read and write the URL.
Installation
npm install @salesforce/ui-bundle-template-feature-react-searchThen import what you need from the package entry point:
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";Quick start
The package ships an example config you can import to try things out
immediately. Render the drop-in <Search> against it:
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";
export default function SearchPage() {
return <Search config={config} title="Search" searchPlaceholder="Search across Salesforce…" />;
}This renders: a search bar, a live total-result count, a scope dropdown (when
the config has 2+ sources), and one results section per source — each with
auto-rendered rows, a filter sidebar, a sort dropdown, per-source pagination,
and a per-source Reset button inside its filter panel. (Reset is per-source;
there is no global reset button.) Set pagination.mode: "merged" and the same
component instead renders one combined grid with a single shared pager
(§9). Everything is driven by the
config you pass in.
For a real application you'll typically pass your own config.
The <Search> component
<Search> is the top of a layered API. Use it as-is, override individual
slots, or drop down to the useSearch hook for full control:
<Search config /> ← drop-in (most apps)
│
├── <SearchBar /> + <SearchResults /> ← compose primitives + own header
│ └── <SourceSection /> per source
│ ├── <DefaultResultRow /> ← override per source
│ └── <DefaultFilterPanel /> ← override per source
│
└── useSearch(config) ← headless: build your own UIProps
| Prop | Type | Default | Purpose |
| ------------------- | ------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------- |
| config | SearchConfig | — (required) | The source list. Pass your own, or the example config export. |
| title | string | "Search" | Heading shown by the default header. |
| subtitle | string | — | Sub-heading shown by the default header. |
| searchPlaceholder | string | "Search…" | Placeholder for the search input. |
| restrictTo | { kind: "sobject"; key: string } | — | Lock the page to one source. Hides the scope dropdown and only fetches/renders that source. |
| showScopeSelector | boolean | true when 2+ sources | Force-show or hide the scope dropdown. Always hidden when restrictTo is set. |
| allScopeLabel | string | "All" | Label for the "search everything" entry in the scope dropdown. |
| renderResult | Record<string, ((node) => ReactNode) \| false> | — | Per source key: custom row renderer, or false to hide that source. |
| renderFilters | Record<string, (() => ReactNode) \| false> | — | Per source key: custom filter sidebar, or false to suppress filters for that source. |
| emptyMessages | Record<string, string> | — | Per source key: message shown when that source has no results. |
| renderHeader | (handle: SearchHandle) => ReactNode | built-in title/subtitle header | Replace the entire header region. |
| className | string | — | Extra classes on the outer container. |
renderResult / renderFilters / emptyMessages are keyed by the source
key from your config (e.g. accounts, contacts).
Creating your own config
A SearchConfig is { sources: SObjectSourceConfig[] } plus an optional global
pagination block. Define it in your own code and pass it to
<Search config={...} />:
import { Search } from "@salesforce/ui-bundle-template-feature-react-search";
import type { SearchConfig } from "@salesforce/ui-bundle-template-feature-react-search";
const config: SearchConfig = {
// One global pagination block — the single source of truth for the whole
// search experience. `mode` / `mergeOrder` / `pageSize` / `pageSizeOptions`
// apply to every object type and in every scope, including when the dropdown
// narrows to one source and on single-object pages (restrictTo / lockedScope).
// Omit to default to per-source mode, page size 10, options [10, 25, 50].
pagination: {
mode: "merged", // "per-source" (default) | "merged"
mergeOrder: "proportional", // "sequential" (default) | "interleaved" | "proportional"
pageSize: 12,
pageSizeOptions: [12, 24, 48],
},
sources: [
{
kind: "sobject",
key: "accounts",
objectName: "Account",
label: "Accounts",
routePattern: "/accounts/:id",
searchableFields: ["Name", "Phone", "Industry"],
displayFields: ["Name", "Industry", "Phone"],
},
],
};
export default function SearchPage() {
return <Search config={config} />;
}One pagination config for everything. There is no per-source pagination
config — config.pagination is the single source of truth. The same
pageSize / pageSizeOptions apply across every object type and in every
scope — the unified /search page, when the scope dropdown narrows to one
source, and single-object pages locked with restrictTo / lockedScope.
How that config is displayed depends on mode: the default "per-source"
paginates each object's section on its own cursor, while "merged" collapses
in-scope sources into one combined grid whose page order is set by mergeOrder
(see §9).
You can also keep the config as a JSON file and import it — cast it to
SearchConfig since the structure is not validated at runtime (see the note at
the end of this section).
Today every source is kind: "sobject", queried through the platform-sdk
uiapi GraphQL bridge.
Source fields (SObjectSourceConfig)
| Field | Required | Type | Description |
| ------------------ | -------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| kind | ✅ | "sobject" | Discriminator selecting the runtime adapter. |
| key | ✅ | string | Stable id — used as the GraphQL alias, URL namespace, and result-map key. Must match /^[A-Za-z_][A-Za-z0-9_]*$/. |
| objectName | ✅ | string | GraphQL object name ("Account", "Contact", …). |
| label | ✅ | string | Plural display label — section header, scope dropdown, empty-state messages ("Accounts"). |
| labelSingular | | string | Singular label for one record ("Account") — used for the merged-grid source badge. Falls back to label. |
| searchableFields | ✅ | string[] | Fields the global q term matches. OR-ed as like %q%. Supports dot-paths ("Owner.Name"). |
| displayFields | ✅ | DisplayField[] | Fields selected in the GraphQL query and used by the default row layout. (See below.) |
| 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). Omit for non-clickable rows. |
| idField | | string (default "Id") | Unique-id field. Drives the :id route token. Always selected automatically — do not list it in displayFields. |
| filterBy | | FilterFieldConfig[] | Structured filters for the source. Today each renders an input in the per-source sidebar. |
| sortBy | | SortFieldConfig[] | Options in the per-source sort dropdown. |
| defaultSort | | { field, direction } | Initial sort applied when the URL has none. direction is "ASC" or "DESC". |
| whereTypeName | | string | GraphQL where variable type. Defaults to ${objectName}_Filter. Override only for non-conventional schemas. |
| orderByTypeName | | string | GraphQL orderBy variable type. Defaults to ${objectName}_OrderBy. |
Global pagination (SearchConfig.pagination)
Optional. One block governs pagination for the whole search experience —
sources carry no pagination config of their own. Omit it to default to
per-source mode, page size 10, options [10, 25, 50]. (Note: mode
"per-source" still paginates each object's section independently — see the
mode row below and §9.)
| Field | Required | Type | Description |
| ----------------- | -------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| pageSize | ✅ | number | Items per page (across all sources in "merged" mode; per source otherwise). |
| pageSizeOptions | ✅ | number[] | Page sizes offered in the "results per page" control. |
| 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. |
| mergeOrder | | "sequential" | "interleaved" | "proportional" (default sequential) | In "merged" mode, how nodes from different sources are ordered. Ignored in "per-source" mode. See §9. |
displayFields shapes
Each entry controls both the GraphQL selection set and the default row
layout (first entry → row title; the rest → subtitle, joined with ·):
| Form | GraphQL emitted | Use for |
| ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| "Name" (string) | Name @optional { value displayValue } | Ordinary scalar fields. |
| { name: "Owner", subfields: ["Name"] } | Owner @optional { Name @optional { … } } | Relationship traversal (parent/lookup). |
| { name: "SomeId", raw: true } | SomeId | Fields not wrapped in { value displayValue } (Id-like scalars). |
The
idField(default"Id") is always emitted — never list it explicitly.
filterBy types
Each FilterFieldConfig is { field, label, type, options?, placeholder?, dateMode?, helpText? }.
The type drives which input renders and how the where clause is built:
| type | UI | where clause |
| --------------- | ---------------------------------- | -------------------------------------------- |
| text | Text input | { field: { like: "%value%" } } |
| picklist | Single-select dropdown | { field: { eq: value } } |
| multipicklist | Multi-select dropdown (checkboxes) | { field: { in: [...] } } (or eq for one) |
| numeric | min / max number inputs | { field: { gte, lte } } |
| boolean | Tri-state (any/true/false) | { field: { eq: true \| false } } |
| date | See dateMode | { field: { gte \| lte: { value } } } |
| daterange | See dateMode | and of gte / lte |
| datetime | See dateMode | start/end-of-day ISO bounds |
| datetimerange | See dateMode | and of start/end-of-day ISO bounds |
For picklist / multipicklist, options are auto-fetched from the GraphQL
aggregate API (groupBy) on first render. Skip the fetch by supplying inline
options: [{ value, label }] — required for fields that aren't group-by-able
(formulas, long text, etc.).
For the date-family types, dateMode selects the comparison chrome (defaults to
"comparison"):
| dateMode | UI |
| -------------- | ------------------------------------------------------------------- |
| "comparison" | An After / Before selector with one date input (default). |
| "range" | A single Between control — two date inputs (lower–upper bound). |
| "both" | A Between / After / Before selector spanning the two. |
sortBy and defaultSort
sortBy is a list of { field, label }. Each becomes an option in the
per-source sort dropdown (with an ASC/DESC toggle). defaultSort sets the
initial order when no sort is present in the URL:
"sortBy": [
{ "field": "CloseDate", "label": "Close Date" },
{ "field": "Amount", "label": "Amount" }
],
"defaultSort": { "field": "CloseDate", "direction": "DESC" }Omit sortBy entirely to hide the sort dropdown for that source.
routePattern tokens
When set, the default row becomes a react-router <Link>:
:id→ the configuredidFieldvalue.:fieldName→ that field's value; supports dot-paths (:Owner.Name).
If any token resolves to null for a record, that row falls back to a
plain, non-clickable layout. Make sure tokenized fields are present in
displayFields.
routePatternonly builds the link — you must supply the destination. This feature turns a result row into a<Link to="/accounts/<id>">; it does not create the page that link points at. For"/accounts/:id"to actually show an account, your app must register a matching route (e.g.<Route path="/accounts/:id" element={<AccountDetail />} />) and implement theAccountDetailpage/component that reads the:idparam and renders that object's details. Without a corresponding route the link resolves to a blank or not-found page. PointroutePatternat routes you already have (or add them alongside), and give every searchable source its own detail route. If you don't want clickable rows — for example when details open in a modal instead of on a route — omitroutePatternentirely (this is what thepropertymanagementapptemplate does: it has noroutePatternand opens a detail modal from a customrenderResultinstead).
A complete source, annotated
{
"kind": "sobject", // discriminator
"key": "leads", // GraphQL alias + URL namespace + result key
"objectName": "Lead", // GraphQL type
"label": "Leads", // plural — section header + scope option
"labelSingular": "Lead", // singular — merged-grid source badge (optional; falls back to label)
"routePattern": "/leads/:id", // default row → <Link to="/leads/<id>">
"idField": "Id", // default; drives :id (don't add to displayFields)
"searchableFields": ["Name", "Company", "Email"], // global q is OR-ed across these
"displayFields": [
"Name", // row title
"Title", // ┐
"Company", // ├ subtitle (joined with " · ")
"Email", // ┘
{ "name": "Owner", "subfields": ["Name"] }, // relationship traversal
],
"filterBy": [
{ "field": "Status", "label": "Status", "type": "picklist" },
{ "field": "Industry", "label": "Industry", "type": "picklist" },
],
"sortBy": [
{ "field": "Name", "label": "Name" },
{ "field": "CreatedDate", "label": "Created Date" },
],
"defaultSort": { "field": "CreatedDate", "direction": "DESC" },
}That single entry gives Leads a full search experience: a global-term match
across name/company/email, two auto-populated picklist filters, a sort
dropdown, pagination (governed by the top-level pagination block), and
clickable result rows linking to /leads/<id>.
No runtime schema validation. A config object is trusted as-is; a typo surfaces at GraphQL query time, not when the config loads. Layer your own validation (e.g. a zod schema) if you need stricter guarantees.
Use cases
1. Unified search across many objects
The default. Every source in the config is searched from one box; a scope dropdown lets the user narrow to a single object. Best for a global "search everything" page.
<Search config={config} title="Search" />2. Single-object search (restrictTo)
Reuse the same config but lock the page to one source. The scope dropdown is hidden and only the matching source is fetched and rendered — ideal for a dedicated "Account search" or "Browse Contacts" page where the object is implied by the route.
<Search
config={config}
title="Search Accounts"
searchPlaceholder="Search by name, phone, or industry…"
restrictTo={{ kind: "sobject", key: "accounts" }}
/>3. Hand off a term from another page
The global term lives in the URL under ?q= (the exported GLOBAL_QUERY_KEY).
Navigate to the search page with ?q= pre-filled and the input + results
populate on arrival — handy for a simple search box on a Home page:
import { useNavigate } from "react-router";
function HomeSearchBox() {
const navigate = useNavigate();
const onSubmit = (term: string) => {
// `q` matches GLOBAL_QUERY_KEY, so the search page pre-fills on arrival.
navigate(`/accounts${term ? `?q=${encodeURIComponent(term)}` : ""}`);
};
// …render an input that calls onSubmit…
}4. Custom row layout for one source
Override the default renderer for specific sources by key; others keep the
default. Return ReactNode from (node) => … — narrow node to your own
type. Use the exported fieldValue() to read { value, displayValue } fields:
import { Search, fieldValue } from "@salesforce/ui-bundle-template-feature-react-search";
<Search
config={config}
renderResult={{
opportunities: (node) => {
const o = node as OpportunityNode;
return (
<div className="flex justify-between">
<span>{fieldValue(o.Name)}</span>
<span className="text-muted-foreground">{fieldValue(o.StageName)}</span>
</div>
);
},
}}
/>;5. Hide a source from the results
Pass false for that source's renderResult. The source skips both the
network request and its UI section:
<Search config={config} renderResult={{ accounts: false }} />6. Replace a source's filter sidebar
Supply your own filter UI built from the exported filter inputs (they wire into
the search state via FilterContext). Pass false to suppress filters
entirely for a source.
import {
Search,
TextFilter,
SelectFilter,
} from "@salesforce/ui-bundle-template-feature-react-search";
<Search
config={config}
renderFilters={{
accounts: () => (
<>
<TextFilter field="Name" label="Account Name" />
{/* SelectFilter / MultiSelectFilter take an explicit `options` list. */}
<SelectFilter
field="Industry"
label="Industry"
options={[
{ value: "Technology", label: "Technology" },
{ value: "Finance", label: "Finance" },
]}
/>
</>
),
}}
/>;7. Headless — drive your own UI with useSearch
Skip the orchestration components entirely and build a bespoke layout. The hook
owns state, URL sync, and fetching; you render whatever you like from the
returned SearchHandle.
import { useSearch } from "@salesforce/ui-bundle-template-feature-react-search";
function MySearch() {
const { q, setQ, sources, loading, error, resetAll } = useSearch(config);
// sources[key].result.nodes, .filters, .sort, .pagination …
// build your own UI from these.
}Lock the headless hook to a single source with
useSearch(config, { lockedScope: "accounts" }).
8. Mixed card layouts per source
renderResult is keyed by source, so each object type can render a completely
different card while the search bar, scope, and pagination stay shared. Sources
without an entry fall back to the default row. Here Accounts render as a stat
card and Contacts as an avatar row:
import { Search, fieldValue } from "@salesforce/ui-bundle-template-feature-react-search";
<Search
config={config}
renderResult={{
// Accounts → a bordered "stat" card.
accounts: (node) => {
const a = node as AccountNode;
return (
<div className="rounded-lg border p-4 flex items-center justify-between">
<div>
<p className="font-semibold">{fieldValue(a.Name)}</p>
<p className="text-sm text-muted-foreground">{fieldValue(a.Industry)}</p>
</div>
<span className="text-sm tabular-nums">{fieldValue(a.AnnualRevenue) ?? "—"}</span>
</div>
);
},
// Contacts → an avatar + two-line card.
contacts: (node) => {
const c = node as ContactNode;
const name = fieldValue(c.Name) ?? "—";
return (
<div className="flex items-center gap-3 py-1">
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
{name.slice(0, 1)}
</span>
<div>
<p className="font-medium">{name}</p>
<p className="text-sm text-muted-foreground">{fieldValue(c.Email)}</p>
</div>
</div>
);
},
// `opportunities` omitted → keeps the built-in default row.
}}
/>;To render every source's results as cards in a responsive grid instead of the
default stacked list, drop down to the headless hook (next example shows the
same pattern) and wrap each source's result.nodes in your own grid container.
9. Global pagination across all sources
By default the drop-in <Search> paginates each source independently. Set
config.pagination.mode: "merged" and <Search> switches to a single combined
grid with one shared pager over the cumulative result set automatically — no
prop needed (that's exactly what §10
assembles by hand for a fully custom UI). Either way, the merged behaviour is
built from three aggregate accessors useSearch derives from the in-scope
sources:
| Accessor | Use |
| ------------------------- | ----------------------------------------------------------------------------- |
| handle.inScopeSources | The SourceController[] participating in the current scope (config order). |
| handle.mergedResults | In-scope nodes flattened into one { sourceKey, node }[] for a single grid. |
| handle.globalPagination | Aggregate pageIndex / pageCount / pageSize / hasNext… + page actions. |
Pick a pagination mode via config.pagination.mode:
| Mode | mergedResults | Page size means… | goToPage |
| -------------- | ------------------------------------ | --------------------------------- | ---------------- |
| "merged" | exactly the current page's window | items per page (e.g. 6 total) | jump to any page |
| "per-source" | every in-scope source's current page | items per source (6 × N) | adjacent only |
Use "merged" when you want a true combined list — "6 per page" shows exactly 6
cards and pageCount = ceil(totalCount / pageSize). The default "per-source"
keeps each source on its own cursor (cheaper for deep lists, but a page can hold
up to sources × pageSize items).
Order sources with config.pagination.mergeOrder:
| Order | mergedResults layout |
| ------------------------ | ----------------------------------------------------------------------- |
| "sequential" (default) | all of source 1, then source 2, … — the first source leads. |
| "interleaved" | round-robin, one per source per round — every source ranks equally. |
| "proportional" | each source spread evenly by its result count — share matches size. |
Pass "interleaved" to give every object type equal footing regardless of how
many results it has; pass "proportional" to weight types by their result count
(a source with 100 hits appears ~10× as often as one with 10, so each page
mirrors the overall mix). Proportional reads each source's totalCount. Page
boundaries stay exact under all three orders.
import {
useSearch,
SearchBar,
DefaultResultRow,
PaginationControls,
} from "@salesforce/ui-bundle-template-feature-react-search";
function GloballyPaginatedSearch() {
// mode + mergeOrder live in config.pagination (e.g. { mode: "merged",
// mergeOrder: "interleaved", pageSize: 6, pageSizeOptions: [6, 12, 24] }).
const handle = useSearch(config);
const { mergedResults, globalPagination: page } = handle;
// One combined, cumulatively-paginated grid. Each item knows its source, so
// you can pick the right card/row per node.
const sourceByKey = Object.fromEntries(
handle.inScopeSources.map((c) => [c.config.key, c.config]),
);
return (
<div className="max-w-4xl mx-auto py-6 space-y-6">
<SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
<p className="text-sm text-muted-foreground">
{page.totalCount} results · Page {page.pageIndex + 1} of {page.pageCount}
</p>
<ul className="divide-y">
{mergedResults.map(({ sourceKey, node }, i) => (
<li key={i} className="py-2">
<DefaultResultRow node={node} source={sourceByKey[sourceKey]} />
</li>
))}
</ul>
{/* Passing pageCount + onGoToPage renders numbered page buttons. */}
<PaginationControls
pageIndex={page.pageIndex}
pageCount={page.pageCount}
hasNextPage={page.hasNextPage}
hasPreviousPage={page.hasPreviousPage}
pageSize={page.pageSize}
pageSizeOptions={page.pageSizeOptions}
onNextPage={page.goToNextPage}
onPreviousPage={page.goToPreviousPage}
onGoToPage={page.goToPage}
onPageSizeChange={page.setPageSize}
disabled={handle.loading}
/>
</div>
);
}These accessors track scope automatically: in "all" scope they span every
source; under a single scope (or lockedScope) they collapse to just that one,
so the same layout serves both the unified page and per-object pages.
See §10 for a complete custom UI — merged grid, pagination widget, per-source filter panels, and a result count — assembled from these accessors and the exported primitives.
How
"merged"is exact. For global pageP, every in-scope source fetches(P + 1) × pageSizerows from its front; concatenating them in config order and slicing[P × pageSize, (P + 1) × pageSize)yields the precise page, because the first(P + 1) × pageSizeitems of the concatenation are always the true cumulative prefix. Cost grows with page depth (each Next refetches a larger window), so it suits a combined grid, not an infinite feed.
"per-source"model. Sources paginate with independent cursors — no global offset — so the "global page" is the furthest-advanced in-scope source's page.goToNextPageadvances only sources that still have data; a source that runs out is left behind and drops out ofmergedResultson later pages (its final page was already shown).pageCountis the max ofceil(totalCount / pageSize)across in-scope sources, andgoToPagehonours only adjacent pages. For fully independent pagers, render one<SourceSection>(orPaginationControls) perhandle.sources[key]and skip the aggregate API.
10. Build a custom unified-results UI (grid + pager + filters + count)
For a combined card grid with one shared pager, a total-results
count, and per-source filter panels, the fastest path is
config.pagination.mode: "merged" on the drop-in <Search> — it renders all of
this (§9). Drop to the headless
recipe below only when you need control the drop-in doesn't expose (a bespoke
grid container, custom chrome placement, extra surrounding UI): assemble it
yourself from useSearch plus the exported primitives. This is what the
propertymanagementapp template's UnifiedSearch component used to hand-roll
before adopting merged mode; the skeleton below is the same recipe, trimmed to
the essentials.
The pieces you compose:
| Piece | What renders it | Source of truth |
| --------------------- | --------------------------------------------------------------- | ------------------------------------- |
| Search box | <SearchBar value={handle.q} onChange={handle.setQ} /> | handle.q / handle.setQ |
| Scope selector | <ScopeSelector config scope onScopeChange /> | handle.scope / handle.setScope |
| Result count | your own <p> reading page.totalCount | handle.globalPagination.totalCount |
| Merged grid | map over handle.mergedResults → your card per sourceKey | handle.mergedResults |
| Pagination widget | <PaginationControls …{page} /> | handle.globalPagination |
| Filter panel | <FilterProvider> + <DefaultFilterPanel source> (per source) | handle.inScopeSources[i] controller |
Two rules make the layout work in every scope — the unified "All" view, a
single source picked in the selector, and a lockedScope page — from one
implementation:
- Render the count on its own row, directly above the grid, unconditionally.
page.totalCountis the cumulative in-scope total, so it's meaningful whether one source or all of them are in scope. Don't gate it on scope. - Show per-source filter chrome only when a single source is in scope. Under
"All", heterogeneous per-source filters can't be combined into one panel, so
collapse to just the count. Detect this with
handle.scope !== ALL_SCOPE(thenhandle.inScopeSources[0]is the sole controller).
import {
useSearch,
SearchBar,
ScopeSelector,
SortControl,
ActiveFilters,
FilterProvider,
FilterResetButton,
DefaultFilterPanel,
PaginationControls,
ALL_SCOPE,
} from "@salesforce/ui-bundle-template-feature-react-search";
function UnifiedSearch({ restrictTo }: { restrictTo?: string }) {
// config.pagination = { mode: "merged", mergeOrder: "proportional",
// pageSize: 6, pageSizeOptions: [6, 12, 24] } — see §9 and §11.
const handle = useSearch(config, { lockedScope: restrictTo });
const { globalPagination: page, mergedResults } = handle;
const singleSourceInScope = handle.scope !== ALL_SCOPE;
const soleController = singleSourceInScope ? handle.inScopeSources[0] : undefined;
return (
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
{/* Row 1 — search box + scope selector (hidden when the scope is locked). */}
<div className="flex flex-wrap items-center gap-3">
<SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
{!handle.scopeLocked && (
<ScopeSelector config={config} scope={handle.scope} onScopeChange={handle.setScope} />
)}
</div>
{/* Row 2 — the sole source's filters + sort, only under a single scope. */}
{singleSourceInScope && soleController && (
<FilterProvider
filters={soleController.filters.active}
onFilterChange={soleController.filters.set}
onFilterRemove={soleController.filters.remove}
onReset={() =>
soleController.filters.active.forEach((f) => soleController.filters.remove(f.field))
}
>
<div className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Filters</h3>
<FilterResetButton size="sm" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DefaultFilterPanel source={soleController.config} />
</div>
{soleController.config.sortBy && (
<SortControl
configs={soleController.config.sortBy}
sort={soleController.sort.current}
onSortChange={soleController.sort.set}
/>
)}
<ActiveFilters
filters={soleController.filters.active}
onRemove={soleController.filters.remove}
/>
</div>
</FilterProvider>
)}
{/* Count — its own row, directly above the grid, in EVERY scope. */}
{!handle.loading && !handle.error && (
<p className="text-sm text-muted-foreground" role="status">
{page.totalCount} total result{page.totalCount === 1 ? "" : "s"}
</p>
)}
{/* Merged, cumulatively-paginated card grid. Each item knows its source. */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mergedResults.map(({ sourceKey, node }, i) => (
<MyCard key={i} sourceKey={sourceKey} node={node} />
))}
</div>
{/* One shared pager over the cumulative set. Passing pageCount + onGoToPage
renders numbered page buttons; omit them for prev/next only. */}
{page.totalCount > 0 && (
<PaginationControls
pageIndex={page.pageIndex}
pageCount={page.pageCount}
hasNextPage={page.hasNextPage}
hasPreviousPage={page.hasPreviousPage}
pageSize={page.pageSize}
pageSizeOptions={page.pageSizeOptions}
onNextPage={page.goToNextPage}
onPreviousPage={page.goToPreviousPage}
onGoToPage={page.goToPage}
onPageSizeChange={page.setPageSize}
disabled={handle.loading || !!handle.error}
/>
)}
</div>
);
}Notes:
MyCardswitches onsourceKeyto pick the right card component per object type (see §8). Read node fields with the exportedfieldValue(), or render a<DefaultResultRow node source>for a built-in row.- Filter primitives are wired through
FilterProvider, not props — anything inside it (DefaultFilterPanel, or individualTextFilter/SelectFilterfrom §6) reads and writes that source's filter state automatically. - The pager needs
mode: "merged"inconfig.paginationfor the "shows exactlypageSizecards,goToPagejumps anywhere" behavior above. Under the default"per-source"mode the same widget still works but paginates each source on its own cursor (see the §9 mode table). - This same component serves single-object pages — pass
restrictTo(a source key) aslockedScope;scopeLockedbecomestrue, the selector hides, andmergedResults/pagecollapse to that one source. The count and pager code paths are unchanged.
11. Choosing a merge order (sequential vs proportional)
config.pagination.mergeOrder decides how "merged"-mode results from
different sources are ordered within each page. It only matters when 2+ sources
are in scope; it's ignored in "per-source" mode and under a single scope.
| Order | Behavior | Reach for it when… |
| ------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| "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. |
| "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). |
| "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. |
Sequential — front-loads by config order. The first source fills the early
pages; later sources only appear once the earlier ones are exhausted. Best when
there's a natural priority. With pageSize: 6 and Accounts (100 hits) before
Contacts (20), page 1 is 6 Accounts, and Contacts don't surface until ~page 17.
const config: SearchConfig = {
pagination: {
mode: "merged",
mergeOrder: "sequential",
pageSize: 6,
pageSizeOptions: [6, 12, 24],
},
sources: [
{ kind: "sobject", key: "accounts" /* … */ }, // leads
{ kind: "sobject", key: "contacts" /* … */ }, // follows
{ kind: "sobject", key: "opportunities" /* … */ }, // last
],
};Proportional — every page reflects the mix. Each source is spread evenly by
its totalCount, so a source with 100 hits appears ~5× as often as one with 20.
With the same 100 Accounts / 20 Contacts and pageSize: 6, roughly every page is
5 Accounts + 1 Contact — the 5:1 ratio of the whole result set — so a user sees
all types from page 1 without one type monopolizing the front.
const config: SearchConfig = {
pagination: {
mode: "merged",
mergeOrder: "proportional",
pageSize: 6,
pageSizeOptions: [6, 12, 24],
},
sources: [
{ kind: "sobject", key: "accounts" /* … */ },
{ kind: "sobject", key: "contacts" /* … */ },
{ kind: "sobject", key: "opportunities" /* … */ },
],
};Proportional reads each source's
totalCountto compute its share, so a source that omitstotalCountfalls back to its fetched node count for weighting. Page boundaries stay exact under all three orders — only the order within the cumulative list changes, never which items belong to pageP.
URL and state conventions
State is reflected in the URL (debounced 300 ms), so searches are bookmarkable and shareable:
| URL param | Meaning |
| ------------------------------- | --------------------------------------------- |
| q | Global search term (broadcast to all sources) |
| scope=<key> | Narrow to one source (omitted = "all") |
| s.<key>.f.<field>=<value> | Single-value filter for a source |
| s.<key>.f.<field>.min=<value> | Range/numeric/date lower bound |
| s.<key>.f.<field>.max=<value> | Range/numeric/date upper bound |
| s.<key>.sort=<field> | Source-specific sort field |
| s.<key>.dir=ASC\|DESC | Source-specific sort direction |
| s.<key>.ps=<n> | Source-specific page size |
| s.<key>.page=<n> | Source-specific 1-based page index |
Changing q resets every source's pagination; changing a source's filter or
sort resets that source's pagination only. Cursors back the prev/next paging
but are not persisted to the URL (only the page index is).
When restrictTo / lockedScope is set, ?scope= is not written — the
route already implies the source.
Public API
Everything is exported from the package entry point:
// Drop-in + example config
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";
// Hooks
import {
useSearch,
useAsyncData,
useDistinctValues,
} from "@salesforce/ui-bundle-template-feature-react-search";
// Composition primitives
import {
SearchBar,
SearchResults,
SourceSection,
SortControl,
PaginationControls,
ScopeSelector,
ActiveFilters,
DefaultResultRow,
DefaultFilterPanel,
} from "@salesforce/ui-bundle-template-feature-react-search";
// Filter inputs + context
import {
FilterProvider,
FilterResetButton,
useFilterField,
useFilterPanel,
TextFilter,
SelectFilter,
MultiSelectFilter,
NumericRangeFilter,
BooleanFilter,
DateRangeFilter,
FilterFieldWrapper,
} from "@salesforce/ui-bundle-template-feature-react-search";
// Utilities / lower-level building blocks
import {
fieldValue,
buildFilter,
buildGlobalQueryClause,
buildOrderBy,
readSourceParams,
writeSourceParams,
GLOBAL_QUERY_KEY,
buildSearchQuery,
runSearch,
fetchDistinctValues,
ALL_SCOPE,
} from "@salesforce/ui-bundle-template-feature-react-search";
// Types
import type {
SearchConfig,
SObjectSourceConfig,
DisplayField,
SourceController,
SourceResult,
SourcePageInfo,
SearchHandle,
SearchScope,
RenderResultFn,
FilterFieldConfig,
FilterFieldType,
ActiveFilterValue,
SortFieldConfig,
SortState,
PicklistOption,
SourceRequest,
SearchQueryPayload,
} from "@salesforce/ui-bundle-template-feature-react-search";Common mistakes
- Missing
searchableFields— the globalqwon't match that source. Provide at least one field, or omit the source whenqis non-empty. - Relationship field without
subfields—"Owner"as a bare string emitsOwner @optional { value displayValue }, which is wrong for a relationship. Use{ name: "Owner", subfields: ["Name"] }. - Listing
idFieldindisplayFields— it's selected automatically; listing it again duplicates the selection. routePatterntoken not indisplayFields— the token can't resolve and every row silently falls back to a non-clickable layout.routePatternwithout a matching route — the feature only builds the<Link>; it doesn't create the destination. If nothing in your app routes"/accounts/:id"to a detail page, the link lands on a blank/not-found view. Register the route + page yourself (or omitroutePatternand render details another way, e.g. a modal).- Non-conforming
key— must match/^[A-Za-z_][A-Za-z0-9_]*$/; it becomes a GraphQL alias and variable prefix. Invalid keys produce malformed documents. - Picklist auto-fetch failing — the aggregate
groupByrequires a group-by-able field. For formulas / long text, supply inlineoptions. - Overriding
whereTypeName/orderByTypeNameneedlessly — only set these when the schema deviates from${ObjectName}_Filter/${ObjectName}_OrderBy. Wrong names cause GraphQL parse errors.
