@salesforce/ui-bundle-template-feature-react-search 11.10.0 → 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 +16 -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/dist/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
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.11.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.10.1...v11.11.0) (2026-07-08)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [11.10.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.10.0...v11.10.1) (2026-07-07)
|
|
15
|
+
|
|
16
|
+
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
## [11.10.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.9.0...v11.10.0) (2026-07-07)
|
|
7
23
|
|
|
8
24
|
**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.11.0",
|
|
22
|
+
"@salesforce/ui-bundle": "^11.11.0",
|
|
23
23
|
"@tailwindcss/vite": "^4.1.17",
|
|
24
24
|
"class-variance-authority": "^0.7.1",
|
|
25
25
|
"clsx": "^2.1.1",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
45
45
|
"@graphql-tools/utils": "^11.0.0",
|
|
46
46
|
"@playwright/test": "^1.49.0",
|
|
47
|
-
"@salesforce/graphiti": "^11.
|
|
48
|
-
"@salesforce/vite-plugin-ui-bundle": "^11.
|
|
47
|
+
"@salesforce/graphiti": "^11.11.0",
|
|
48
|
+
"@salesforce/vite-plugin-ui-bundle": "^11.11.0",
|
|
49
49
|
"@testing-library/jest-dom": "^6.6.3",
|
|
50
50
|
"@testing-library/react": "^16.1.0",
|
|
51
51
|
"@testing-library/user-event": "^14.5.2",
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merged results layout — a single combined grid across every in-scope source,
|
|
3
|
+
* driven by one global pager over the cumulative result set.
|
|
4
|
+
*
|
|
5
|
+
* This is the counterpart to {@link SearchResults} (which renders one stacked
|
|
6
|
+
* section per source). Use it when you want a unified "search everything" grid:
|
|
7
|
+
* heterogeneous cards laid out together, one shared pagination control, and a
|
|
8
|
+
* single results count — the layout the drop-in `<Search>` renders when the
|
|
9
|
+
* config sets `pagination.mode: "merged"`, and the same shape most apps
|
|
10
|
+
* hand-build on top of `useSearch`.
|
|
11
|
+
*
|
|
12
|
+
* It reads three aggregate accessors off the {@link SearchHandle}
|
|
13
|
+
* (`mergedResults`, `globalPagination`, `inScopeSources`), so it honours the
|
|
14
|
+
* config's `pagination.mode` / `mergeOrder` automatically. For a true combined
|
|
15
|
+
* grid, pair it with `pagination.mode: "merged"` (see the feature README, §9).
|
|
16
|
+
*
|
|
17
|
+
* Layout, top to bottom:
|
|
18
|
+
* - **Single-source filter chrome** — when the scope is narrowed to one
|
|
19
|
+
* source (selected or `lockedScope`), that source's filter panel + sort +
|
|
20
|
+
* active-filter chips render here. Under "All", per-source filters can't be
|
|
21
|
+
* combined across heterogeneous objects, so this row is omitted.
|
|
22
|
+
* - **Results count** — the cumulative in-scope total, on its own row directly
|
|
23
|
+
* above the grid, in every scope. Hidden when there are no results.
|
|
24
|
+
* - **The grid** — one card per `mergedResults` item, rendered via the
|
|
25
|
+
* per-source `renderResult[sourceKey]` callback. In a mixed grid (2+ sources
|
|
26
|
+
* in scope) each card is topped with an auto source badge — the source's
|
|
27
|
+
* `labelSingular` (falling back to `label`), colored from a palette by its
|
|
28
|
+
* config order — so heterogeneous results stay self-identifying with no
|
|
29
|
+
* configuration. Skeletons while loading; an empty-state message when there
|
|
30
|
+
* are none.
|
|
31
|
+
* - **Global pager** — one {@link PaginationControls} over the cumulative set.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { ReactNode } from "react";
|
|
35
|
+
import { AlertCircle } from "lucide-react";
|
|
36
|
+
import { Alert, AlertDescription, AlertTitle } from "../../../components/ui/alert";
|
|
37
|
+
import { Skeleton } from "../../../components/ui/skeleton";
|
|
38
|
+
import { ActiveFilters } from "./filters/ActiveFilters";
|
|
39
|
+
import { DefaultFilterPanel } from "./filters/DefaultFilterPanel";
|
|
40
|
+
import { FilterProvider, FilterResetButton } from "./filters/FilterContext";
|
|
41
|
+
import { PaginationControls } from "./controls/PaginationControls";
|
|
42
|
+
import { SortControl } from "./controls/SortControl";
|
|
43
|
+
import { DefaultResultRow } from "./results/DefaultResultRow";
|
|
44
|
+
import { ALL_SCOPE, type SearchHandle, type SourceController } from "../types";
|
|
45
|
+
|
|
46
|
+
type ResultRenderer = ((node: unknown) => ReactNode) | false;
|
|
47
|
+
|
|
48
|
+
export interface MergedSearchResultsProps {
|
|
49
|
+
handle: SearchHandle;
|
|
50
|
+
/**
|
|
51
|
+
* Per source key: how to render one node in the merged grid. Falls back to
|
|
52
|
+
* the built-in {@link DefaultResultRow} for any source without an entry.
|
|
53
|
+
* Pass `false` to skip a source's cards entirely.
|
|
54
|
+
*/
|
|
55
|
+
renderResult?: Record<string, ResultRenderer>;
|
|
56
|
+
/** Message shown when there are no results. Defaults to "No results found." */
|
|
57
|
+
emptyMessage?: string;
|
|
58
|
+
/** Number of skeleton placeholders to show while loading. Defaults to `pageSize`. */
|
|
59
|
+
skeletonCount?: number;
|
|
60
|
+
/** Extra classes on the grid container. Defaults to a responsive 1/2/3-column grid. */
|
|
61
|
+
gridClassName?: string;
|
|
62
|
+
/** Extra classes on the outer wrapper. */
|
|
63
|
+
className?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DEFAULT_GRID_CLASS = "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Palette for the auto source badges in a mixed grid. A source's color is its
|
|
70
|
+
* index in the config's `sources` array, wrapped around the palette — so badges
|
|
71
|
+
* stay stable and distinct without any per-source configuration. Add more
|
|
72
|
+
* entries here if you routinely mix more source types than there are colors.
|
|
73
|
+
*/
|
|
74
|
+
const BADGE_PALETTE = [
|
|
75
|
+
"bg-blue-100 text-blue-700",
|
|
76
|
+
"bg-amber-100 text-amber-700",
|
|
77
|
+
"bg-purple-100 text-purple-700",
|
|
78
|
+
"bg-pink-100 text-pink-700",
|
|
79
|
+
"bg-emerald-100 text-emerald-700",
|
|
80
|
+
"bg-sky-100 text-sky-700",
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
export function MergedSearchResults({
|
|
84
|
+
handle,
|
|
85
|
+
renderResult,
|
|
86
|
+
emptyMessage,
|
|
87
|
+
skeletonCount,
|
|
88
|
+
gridClassName = DEFAULT_GRID_CLASS,
|
|
89
|
+
className,
|
|
90
|
+
}: MergedSearchResultsProps) {
|
|
91
|
+
const { globalPagination: page, mergedResults, loading, error } = handle;
|
|
92
|
+
|
|
93
|
+
// A single source is in scope when the scope is narrowed (selected from the
|
|
94
|
+
// dropdown) or locked (`lockedScope` / restrictTo). Its filter chrome can
|
|
95
|
+
// render meaningfully; under "All" it can't (heterogeneous sources).
|
|
96
|
+
const singleSourceInScope = handle.scope !== ALL_SCOPE;
|
|
97
|
+
const soleController = singleSourceInScope ? handle.inScopeSources[0] : undefined;
|
|
98
|
+
// Per-card source badges only make sense when the grid is genuinely mixed —
|
|
99
|
+
// a single-source grid (narrowed or locked) has one type throughout.
|
|
100
|
+
const showSourceBadges = !singleSourceInScope;
|
|
101
|
+
// Assign each source a stable badge color by its declaration order. Badges
|
|
102
|
+
// only show in "All" scope, where `inScopeSources` is every source in config
|
|
103
|
+
// order, so the index here is the source's config position.
|
|
104
|
+
const badgeColorByKey: Record<string, string> = {};
|
|
105
|
+
handle.inScopeSources.forEach((controller, i) => {
|
|
106
|
+
badgeColorByKey[controller.config.key] = BADGE_PALETTE[i % BADGE_PALETTE.length];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const skeletons = skeletonCount ?? page.pageSize;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={`space-y-6 ${className ?? ""}`}>
|
|
113
|
+
{soleController && <SingleSourceChrome controller={soleController} />}
|
|
114
|
+
|
|
115
|
+
{error && (
|
|
116
|
+
<Alert variant="destructive" role="alert">
|
|
117
|
+
<AlertCircle />
|
|
118
|
+
<AlertTitle>Search failed</AlertTitle>
|
|
119
|
+
<AlertDescription>{error}</AlertDescription>
|
|
120
|
+
</Alert>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{/* Results count — its own row, directly above the grid, in every scope.
|
|
124
|
+
Hidden when there are none (the empty state covers that case). */}
|
|
125
|
+
{!loading && !error && page.totalCount > 0 && (
|
|
126
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
127
|
+
{page.totalCount} result{page.totalCount === 1 ? "" : "s"}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
{loading ? (
|
|
132
|
+
<div className={gridClassName}>
|
|
133
|
+
{Array.from({ length: Math.min(skeletons, 6) }, (_, i) => (
|
|
134
|
+
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
) : mergedResults.length === 0 ? (
|
|
138
|
+
<p className="py-12 text-center text-sm text-muted-foreground">
|
|
139
|
+
{emptyMessage ?? "No results found."}
|
|
140
|
+
</p>
|
|
141
|
+
) : (
|
|
142
|
+
<div className={gridClassName}>
|
|
143
|
+
{mergedResults.map(({ sourceKey, node }, i) => {
|
|
144
|
+
const override = renderResult?.[sourceKey];
|
|
145
|
+
if (override === false) return null;
|
|
146
|
+
const source = handle.sources[sourceKey]?.config;
|
|
147
|
+
const rendered = override ? (
|
|
148
|
+
override(node)
|
|
149
|
+
) : source ? (
|
|
150
|
+
<DefaultResultRow node={node} source={source} />
|
|
151
|
+
) : null;
|
|
152
|
+
const badge =
|
|
153
|
+
showSourceBadges && source ? (
|
|
154
|
+
<SourceBadge
|
|
155
|
+
label={source.labelSingular ?? source.label}
|
|
156
|
+
colorClass={badgeColorByKey[sourceKey]}
|
|
157
|
+
/>
|
|
158
|
+
) : null;
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
key={getItemKey(sourceKey, node, i)}
|
|
162
|
+
className="flex flex-col gap-1.5 [&>:last-child]:min-h-0 [&>:last-child]:flex-1"
|
|
163
|
+
>
|
|
164
|
+
{badge}
|
|
165
|
+
{rendered}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{page.totalCount > 0 && (
|
|
173
|
+
<PaginationControls
|
|
174
|
+
pageIndex={page.pageIndex}
|
|
175
|
+
pageCount={page.pageCount}
|
|
176
|
+
hasNextPage={page.hasNextPage}
|
|
177
|
+
hasPreviousPage={page.hasPreviousPage}
|
|
178
|
+
pageSize={page.pageSize}
|
|
179
|
+
pageSizeOptions={page.pageSizeOptions}
|
|
180
|
+
onNextPage={page.goToNextPage}
|
|
181
|
+
onPreviousPage={page.goToPreviousPage}
|
|
182
|
+
onGoToPage={page.goToPage}
|
|
183
|
+
onPageSizeChange={page.setPageSize}
|
|
184
|
+
disabled={loading || !!error}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getItemKey(sourceKey: string, node: unknown, fallback: number): string {
|
|
192
|
+
const id = node && typeof node === "object" ? (node as { Id?: unknown }).Id : undefined;
|
|
193
|
+
return typeof id === "string" ? `${sourceKey}:${id}` : `${sourceKey}:${fallback}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface SourceBadgeProps {
|
|
197
|
+
label: string;
|
|
198
|
+
colorClass: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Auto source badge shown above each card in a mixed grid — the source's
|
|
203
|
+
* `labelSingular` (falling back to `label`), colored from {@link BADGE_PALETTE}
|
|
204
|
+
* by its config order. Identifies which object type a heterogeneous card came
|
|
205
|
+
* from with no per-app config.
|
|
206
|
+
*/
|
|
207
|
+
function SourceBadge({ label, colorClass }: SourceBadgeProps) {
|
|
208
|
+
return (
|
|
209
|
+
<span className={`w-fit rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}`}>
|
|
210
|
+
{label}
|
|
211
|
+
</span>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface SingleSourceChromeProps {
|
|
216
|
+
controller: SourceController;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Filter panel + sort + active-filter chips for the one source in scope. Shown
|
|
221
|
+
* whenever the search is narrowed to a single object (selected or locked).
|
|
222
|
+
* Hidden under "All", where heterogeneous per-source filters can't be combined.
|
|
223
|
+
*/
|
|
224
|
+
function SingleSourceChrome({ controller }: SingleSourceChromeProps) {
|
|
225
|
+
const { config: source, filters, sort } = controller;
|
|
226
|
+
const showFilterPanel = (source.filterBy?.length ?? 0) > 0;
|
|
227
|
+
const showSortControl = (source.sortBy?.length ?? 0) > 0;
|
|
228
|
+
const showActiveChips = filters.active.length > 0;
|
|
229
|
+
|
|
230
|
+
if (!showFilterPanel && !showSortControl && !showActiveChips) return null;
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className="flex flex-col gap-4">
|
|
234
|
+
{showFilterPanel && (
|
|
235
|
+
<FilterProvider
|
|
236
|
+
filters={filters.active}
|
|
237
|
+
onFilterChange={filters.set}
|
|
238
|
+
onFilterRemove={filters.remove}
|
|
239
|
+
onReset={() => filters.active.forEach((f) => filters.remove(f.field))}
|
|
240
|
+
>
|
|
241
|
+
<div className="rounded-md border p-3 space-y-3">
|
|
242
|
+
<div className="flex items-center justify-between">
|
|
243
|
+
<h3 className="text-sm font-semibold">Filters</h3>
|
|
244
|
+
<FilterResetButton size="sm" />
|
|
245
|
+
</div>
|
|
246
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
247
|
+
<DefaultFilterPanel source={source} />
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</FilterProvider>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{(showSortControl || showActiveChips) && (
|
|
254
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
255
|
+
{showSortControl && source.sortBy && (
|
|
256
|
+
<SortControl configs={source.sortBy} sort={sort.current} onSortChange={sort.set} />
|
|
257
|
+
)}
|
|
258
|
+
{showActiveChips && <ActiveFilters filters={filters.active} onRemove={filters.remove} />}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Drop-in search component. Given a `SearchConfig`, renders a complete
|
|
3
|
-
* search experience: search bar, an aggregate result count
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* search experience: search bar, an aggregate result count, and results.
|
|
4
|
+
*
|
|
5
|
+
* The results layout follows the config's pagination mode — no separate prop:
|
|
6
|
+
* - `pagination.mode: "per-source"` (default): one stacked section per
|
|
7
|
+
* source, each with its own filters, sort, and pagination.
|
|
8
|
+
* - `pagination.mode: "merged"`: a single combined grid across every in-scope
|
|
9
|
+
* source, driven by one global pager over the cumulative result set (with a
|
|
10
|
+
* results count and, under a single scope, that source's filter chrome).
|
|
6
11
|
*
|
|
7
12
|
* The simplest possible integration:
|
|
8
13
|
*
|
|
@@ -28,6 +33,7 @@ import { useSearch } from "../hooks/useSearch";
|
|
|
28
33
|
import { SearchBar } from "./controls/SearchBar";
|
|
29
34
|
import { ScopeSelector } from "./controls/ScopeSelector";
|
|
30
35
|
import { SearchResults } from "./SearchResults";
|
|
36
|
+
import { MergedSearchResults } from "./MergedSearchResults";
|
|
31
37
|
import { ALL_SCOPE } from "../types";
|
|
32
38
|
import type { SearchConfig, SearchHandle, SObjectSourceConfig } from "../types";
|
|
33
39
|
|
|
@@ -108,6 +114,10 @@ export function Search({
|
|
|
108
114
|
}, [restrictTo, config.sources]);
|
|
109
115
|
|
|
110
116
|
const handle = useSearch(config, { lockedScope });
|
|
117
|
+
// The results layout follows the pagination mode — a "merged" config means a
|
|
118
|
+
// single combined grid, "per-source" (default) means one section per source.
|
|
119
|
+
// There is no separate `layout` prop: the two would only ever be set in lockstep.
|
|
120
|
+
const merged = config.pagination?.mode === "merged";
|
|
111
121
|
const totalResults = Object.values(handle.sources).reduce(
|
|
112
122
|
(acc, controller) => acc + (controller.result?.totalCount ?? 0),
|
|
113
123
|
0,
|
|
@@ -119,7 +129,9 @@ export function Search({
|
|
|
119
129
|
// and that source's own section already shows its count — so suppress the
|
|
120
130
|
// redundant total here.
|
|
121
131
|
const sourcesInScope = handle.scope === ALL_SCOPE ? config.sources.length : 1;
|
|
122
|
-
|
|
132
|
+
// In merged mode the results grid renders its own count row directly above the
|
|
133
|
+
// grid (in every scope), so the inline aggregate here would be redundant.
|
|
134
|
+
const showTotalResults = !merged && sourcesInScope >= 2;
|
|
123
135
|
|
|
124
136
|
return (
|
|
125
137
|
<div className={`max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6 ${className ?? ""}`}>
|
|
@@ -142,19 +154,32 @@ export function Search({
|
|
|
142
154
|
allLabel={allScopeLabel}
|
|
143
155
|
/>
|
|
144
156
|
)}
|
|
145
|
-
{showTotalResults && !handle.loading && (
|
|
146
|
-
<span className="text-sm text-muted-foreground">
|
|
147
|
-
{totalResults} total result{totalResults === 1 ? "" : "s"}
|
|
148
|
-
</span>
|
|
149
|
-
)}
|
|
150
157
|
</div>
|
|
151
158
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
{merged ? (
|
|
160
|
+
<MergedSearchResults
|
|
161
|
+
handle={handle}
|
|
162
|
+
renderResult={renderResult}
|
|
163
|
+
emptyMessage={emptyMessages?.[handle.scope]}
|
|
164
|
+
/>
|
|
165
|
+
) : (
|
|
166
|
+
<>
|
|
167
|
+
{/* Aggregate count on its own row, directly above the results —
|
|
168
|
+
only in "All" scope, where the per-source sections don't each
|
|
169
|
+
carry a single meaningful total. */}
|
|
170
|
+
{showTotalResults && !handle.loading && (
|
|
171
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
172
|
+
{totalResults} total result{totalResults === 1 ? "" : "s"}
|
|
173
|
+
</p>
|
|
174
|
+
)}
|
|
175
|
+
<SearchResults
|
|
176
|
+
handle={handle}
|
|
177
|
+
renderResult={renderResult}
|
|
178
|
+
renderFilters={renderFilters}
|
|
179
|
+
emptyMessages={emptyMessages}
|
|
180
|
+
/>
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
158
183
|
</div>
|
|
159
184
|
);
|
|
160
185
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `displayFields[0]`, subtitle from the rest, optional `<Link>` driven
|
|
8
8
|
* by `routePattern`.
|
|
9
9
|
* - `renderFilters[sourceKey]` — sidebar filter UI. Default: one input per
|
|
10
|
-
* `
|
|
10
|
+
* `filterBy` entry, with picklist options resolved from inline
|
|
11
11
|
* config or auto-fetched from the GraphQL aggregate API.
|
|
12
12
|
*
|
|
13
13
|
* Pass `false` for either entry to suppress the default for that source.
|
|
@@ -52,7 +52,7 @@ export function SearchResults({
|
|
|
52
52
|
overrideResult ?? ((node) => <DefaultResultRow node={node} source={controller.config} />);
|
|
53
53
|
|
|
54
54
|
const overrideFilters = renderFilters?.[key];
|
|
55
|
-
const hasFilterFields = (controller.config.
|
|
55
|
+
const hasFilterFields = (controller.config.filterBy?.length ?? 0) > 0;
|
|
56
56
|
const resolvedRenderFilters: (() => ReactNode) | undefined =
|
|
57
57
|
overrideFilters === false
|
|
58
58
|
? undefined
|
|
@@ -52,11 +52,11 @@ export function SourceSection({
|
|
|
52
52
|
const showResults = !loading && !error && nodes.length > 0;
|
|
53
53
|
const showEmpty = !loading && !error && nodes.length === 0;
|
|
54
54
|
|
|
55
|
-
const pageSizeOptions =
|
|
55
|
+
const pageSizeOptions = pagination.pageSizeOptions ?? [pagination.pageSize];
|
|
56
56
|
|
|
57
57
|
const showFilterPanel = !!renderFilters && !hideFilterChrome;
|
|
58
58
|
const showActiveChips = !hideFilterChrome && filters.active.length > 0;
|
|
59
|
-
const showSortControl = !hideFilterChrome && (config.
|
|
59
|
+
const showSortControl = !hideFilterChrome && (config.sortBy?.length ?? 0) > 0;
|
|
60
60
|
|
|
61
61
|
return (
|
|
62
62
|
<section className={`flex flex-col gap-6 ${className ?? ""}`}>
|
|
@@ -82,21 +82,12 @@ export function SourceSection({
|
|
|
82
82
|
<div className="min-w-0">
|
|
83
83
|
<header className="flex flex-wrap items-baseline gap-3 mb-3">
|
|
84
84
|
<h2 className="text-lg font-semibold">{config.label}</h2>
|
|
85
|
-
{totalCount != null && (
|
|
86
|
-
<span className="text-sm text-muted-foreground">
|
|
87
|
-
{totalCount} {totalCount === 1 ? "result" : "results"}
|
|
88
|
-
</span>
|
|
89
|
-
)}
|
|
90
85
|
</header>
|
|
91
86
|
|
|
92
87
|
{(showSortControl || showActiveChips) && (
|
|
93
88
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
94
|
-
{showSortControl && config.
|
|
95
|
-
<SortControl
|
|
96
|
-
configs={config.sortFields}
|
|
97
|
-
sort={sort.current}
|
|
98
|
-
onSortChange={sort.set}
|
|
99
|
-
/>
|
|
89
|
+
{showSortControl && config.sortBy ? (
|
|
90
|
+
<SortControl configs={config.sortBy} sort={sort.current} onSortChange={sort.set} />
|
|
100
91
|
) : null}
|
|
101
92
|
{showActiveChips && (
|
|
102
93
|
<ActiveFilters filters={filters.active} onRemove={filters.remove} />
|
|
@@ -104,6 +95,14 @@ export function SourceSection({
|
|
|
104
95
|
</div>
|
|
105
96
|
)}
|
|
106
97
|
|
|
98
|
+
{/* Result count, shown just above the results in every scope —
|
|
99
|
+
including a locked / single-source (restrictTo) view. */}
|
|
100
|
+
{!loading && !error && totalCount != null && (
|
|
101
|
+
<p className="text-sm text-muted-foreground mb-3">
|
|
102
|
+
{totalCount} {totalCount === 1 ? "result" : "results"}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
|
|
107
106
|
<div className="min-h-32">
|
|
108
107
|
{loading && (
|
|
109
108
|
<div className="space-y-2">
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Pagination,
|
|
3
3
|
PaginationContent,
|
|
4
|
+
PaginationEllipsis,
|
|
4
5
|
PaginationItem,
|
|
6
|
+
PaginationLink,
|
|
5
7
|
PaginationNext,
|
|
6
8
|
PaginationPrevious,
|
|
7
9
|
} from "../../../../components/ui/pagination";
|
|
@@ -24,10 +26,43 @@ interface PaginationControlsProps {
|
|
|
24
26
|
onNextPage: () => void;
|
|
25
27
|
onPreviousPage: () => void;
|
|
26
28
|
onPageSizeChange: (size: number) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Total number of pages. When provided (with {@link onGoToPage}) and `> 1`,
|
|
31
|
+
* the control renders numbered page buttons with ellipsis truncation instead
|
|
32
|
+
* of a plain "Page N" label. Omit for cursor-style sources where the total
|
|
33
|
+
* page count is unknown.
|
|
34
|
+
*/
|
|
35
|
+
pageCount?: number;
|
|
36
|
+
/** Jump to a 0-based page. Required to render numbered page buttons. */
|
|
37
|
+
onGoToPage?: (pageIndex: number) => void;
|
|
27
38
|
disabled?: boolean;
|
|
28
39
|
idPrefix?: string;
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Builds the list of page tokens to render: 0-based page numbers plus
|
|
44
|
+
* `"ellipsis"` gap markers. Always shows the first and last page, a window
|
|
45
|
+
* around the current page, and collapses the rest. e.g. for current=5,
|
|
46
|
+
* total=12 → [0, ellipsis, 4, 5, 6, ellipsis, 11].
|
|
47
|
+
*/
|
|
48
|
+
function getPageTokens(currentIndex: number, pageCount: number): Array<number | "ellipsis"> {
|
|
49
|
+
const SIBLINGS = 1; // pages shown on each side of the current page
|
|
50
|
+
const last = pageCount - 1;
|
|
51
|
+
const pages = new Set<number>([0, last]);
|
|
52
|
+
for (let p = currentIndex - SIBLINGS; p <= currentIndex + SIBLINGS; p++) {
|
|
53
|
+
if (p >= 0 && p <= last) pages.add(p);
|
|
54
|
+
}
|
|
55
|
+
const sorted = [...pages].sort((a, b) => a - b);
|
|
56
|
+
const tokens: Array<number | "ellipsis"> = [];
|
|
57
|
+
let prev = -1;
|
|
58
|
+
for (const p of sorted) {
|
|
59
|
+
if (prev !== -1 && p - prev > 1) tokens.push("ellipsis");
|
|
60
|
+
tokens.push(p);
|
|
61
|
+
prev = p;
|
|
62
|
+
}
|
|
63
|
+
return tokens;
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
export function PaginationControls({
|
|
32
67
|
pageIndex,
|
|
33
68
|
hasNextPage,
|
|
@@ -37,6 +72,8 @@ export function PaginationControls({
|
|
|
37
72
|
onNextPage,
|
|
38
73
|
onPreviousPage,
|
|
39
74
|
onPageSizeChange,
|
|
75
|
+
pageCount,
|
|
76
|
+
onGoToPage,
|
|
40
77
|
disabled = false,
|
|
41
78
|
idPrefix = "page-size",
|
|
42
79
|
}: PaginationControlsProps) {
|
|
@@ -48,6 +85,9 @@ export function PaginationControls({
|
|
|
48
85
|
const nextDisabled = disabled || !hasNextPage;
|
|
49
86
|
const selectId = `${idPrefix}-select`;
|
|
50
87
|
|
|
88
|
+
// Numbered page buttons require both a known page count and a seek callback.
|
|
89
|
+
const showNumberedPages = pageCount != null && pageCount > 1 && onGoToPage != null;
|
|
90
|
+
|
|
51
91
|
// The underlying pagination control renders an <a> without an href, so it is
|
|
52
92
|
// not keyboard-operable on its own. Activate it on Enter/Space to match the
|
|
53
93
|
// behaviour of a native button (WCAG 2.2 SC 2.1.1 Keyboard).
|
|
@@ -93,11 +133,41 @@ export function PaginationControls({
|
|
|
93
133
|
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
94
134
|
/>
|
|
95
135
|
</PaginationItem>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
|
|
137
|
+
{showNumberedPages ? (
|
|
138
|
+
getPageTokens(pageIndex, pageCount).map((token, i) =>
|
|
139
|
+
token === "ellipsis" ? (
|
|
140
|
+
<PaginationItem key={`ellipsis-${i}`}>
|
|
141
|
+
<PaginationEllipsis />
|
|
142
|
+
</PaginationItem>
|
|
143
|
+
) : (
|
|
144
|
+
<PaginationItem key={token}>
|
|
145
|
+
<PaginationLink
|
|
146
|
+
role="button"
|
|
147
|
+
tabIndex={disabled ? -1 : 0}
|
|
148
|
+
isActive={token === pageIndex}
|
|
149
|
+
aria-label={`Go to page ${token + 1}`}
|
|
150
|
+
aria-current={token === pageIndex ? "page" : undefined}
|
|
151
|
+
onClick={disabled ? undefined : () => onGoToPage(token)}
|
|
152
|
+
onKeyDown={disabled ? undefined : handleActivationKey(() => onGoToPage(token))}
|
|
153
|
+
aria-disabled={disabled}
|
|
154
|
+
className={disabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
155
|
+
>
|
|
156
|
+
{token + 1}
|
|
157
|
+
</PaginationLink>
|
|
158
|
+
</PaginationItem>
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
) : (
|
|
162
|
+
<PaginationItem>
|
|
163
|
+
<span className="min-w-16 text-center text-sm text-muted-foreground px-2">
|
|
164
|
+
{pageCount != null && pageCount > 0
|
|
165
|
+
? `Page ${pageIndex + 1} of ${pageCount}`
|
|
166
|
+
: `Page ${pageIndex + 1}`}
|
|
167
|
+
</span>
|
|
168
|
+
</PaginationItem>
|
|
169
|
+
)}
|
|
170
|
+
|
|
101
171
|
<PaginationItem>
|
|
102
172
|
<PaginationNext
|
|
103
173
|
role="button"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Default filter panel used when no `renderFilters` is supplied for a source.
|
|
3
3
|
*
|
|
4
|
-
* Renders one input per entry in `source.
|
|
4
|
+
* Renders one input per entry in `source.filterBy`, picking the right
|
|
5
5
|
* component for each `FilterFieldType`:
|
|
6
6
|
*
|
|
7
7
|
* - text → TextFilter
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - numeric → NumericRangeFilter
|
|
11
11
|
* - boolean → BooleanFilter
|
|
12
12
|
* - date / daterange / datetime / datetimerange → DateRangeFilter
|
|
13
|
+
* (its Between / After / Before chrome is chosen by `filter.dateMode`)
|
|
13
14
|
*
|
|
14
15
|
* Applications can still pass `renderFilters[sourceKey]` to override the
|
|
15
16
|
* entire panel for a specific source.
|
|
@@ -30,7 +31,7 @@ interface DefaultFilterPanelProps {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export function DefaultFilterPanel({ source }: DefaultFilterPanelProps) {
|
|
33
|
-
const filters = source.
|
|
34
|
+
const filters = source.filterBy ?? [];
|
|
34
35
|
if (filters.length === 0) return null;
|
|
35
36
|
return (
|
|
36
37
|
<>
|
|
@@ -75,6 +76,7 @@ function DefaultFilterField({ filter, source }: DefaultFilterFieldProps) {
|
|
|
75
76
|
label={filter.label}
|
|
76
77
|
helpText={filter.helpText}
|
|
77
78
|
filterType="daterange"
|
|
79
|
+
mode={filter.dateMode}
|
|
78
80
|
/>
|
|
79
81
|
);
|
|
80
82
|
case "datetime":
|
|
@@ -85,6 +87,7 @@ function DefaultFilterField({ filter, source }: DefaultFilterFieldProps) {
|
|
|
85
87
|
label={filter.label}
|
|
86
88
|
helpText={filter.helpText}
|
|
87
89
|
filterType="datetimerange"
|
|
90
|
+
mode={filter.dateMode}
|
|
88
91
|
/>
|
|
89
92
|
);
|
|
90
93
|
default:
|