@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/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts
CHANGED
|
@@ -40,8 +40,17 @@ export interface SObjectSourceConfig {
|
|
|
40
40
|
key: string;
|
|
41
41
|
/** GraphQL object name (e.g. "Account", "Contact"). */
|
|
42
42
|
objectName: string;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Plural display label — used in section headers, the scope dropdown, and
|
|
45
|
+
* empty-state messages (e.g. "Accounts", "Maintenance Requests").
|
|
46
|
+
*/
|
|
44
47
|
label: string;
|
|
48
|
+
/**
|
|
49
|
+
* Singular display label for a single record of this type (e.g. "Account",
|
|
50
|
+
* "Maintenance Request"). Used for the per-card source badge in the merged
|
|
51
|
+
* grid. Falls back to `label` when omitted.
|
|
52
|
+
*/
|
|
53
|
+
labelSingular?: string;
|
|
45
54
|
/** Field name that uniquely identifies a record. Defaults to "Id". */
|
|
46
55
|
idField?: string;
|
|
47
56
|
/**
|
|
@@ -67,16 +76,20 @@ export interface SObjectSourceConfig {
|
|
|
67
76
|
* automatically; do not list it.
|
|
68
77
|
*/
|
|
69
78
|
displayFields: DisplayField[];
|
|
70
|
-
/**
|
|
71
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Optional structured filters for this source.
|
|
81
|
+
*
|
|
82
|
+
* Named `filterBy` (rather than "the filters shown in the panel") because a
|
|
83
|
+
* future entry may constrain the query without rendering a control — e.g. a
|
|
84
|
+
* default `Status != Completed` that hides completed items from results while
|
|
85
|
+
* never appearing in the UI. Today every entry renders an input via
|
|
86
|
+
* `DefaultFilterPanel`.
|
|
87
|
+
*/
|
|
88
|
+
filterBy?: FilterFieldConfig[];
|
|
72
89
|
/** Optional sort options surfaced in the per-source sort dropdown. */
|
|
73
|
-
|
|
90
|
+
sortBy?: SortFieldConfig[];
|
|
74
91
|
/** Initial sort applied when no URL sort is present. */
|
|
75
92
|
defaultSort?: SortState;
|
|
76
|
-
/** Default page size for this source. */
|
|
77
|
-
pageSize?: number;
|
|
78
|
-
/** Allowed page sizes. */
|
|
79
|
-
validPageSizes?: number[];
|
|
80
93
|
/**
|
|
81
94
|
* GraphQL type name for the `where` variable.
|
|
82
95
|
* Defaults to `${objectName}_Filter` — override only for non-conventional schemas.
|
|
@@ -86,9 +99,48 @@ export interface SObjectSourceConfig {
|
|
|
86
99
|
orderByTypeName?: string;
|
|
87
100
|
}
|
|
88
101
|
|
|
102
|
+
/**
|
|
103
|
+
* One global pagination config for the whole search experience. It is the
|
|
104
|
+
* single source of truth — there is no per-source pagination. The same
|
|
105
|
+
* `pageSize` / `pageSizeOptions` apply across every object type and in every
|
|
106
|
+
* scope, including single-object pages (`restrictTo` / `lockedScope`) and when
|
|
107
|
+
* the scope dropdown narrows to one source.
|
|
108
|
+
*/
|
|
109
|
+
export interface SearchPaginationConfig {
|
|
110
|
+
/**
|
|
111
|
+
* How results are paginated:
|
|
112
|
+
* - `"per-source"` (default): each source advances its own cursor; a page
|
|
113
|
+
* can hold up to `sources × pageSize` items. This is what the drop-in
|
|
114
|
+
* `<Search>` (one section per source) expects.
|
|
115
|
+
* - `"merged"`: treat the cumulative result set as one list and window it
|
|
116
|
+
* into fixed pages of `pageSize`. Each page shows exactly `pageSize` items
|
|
117
|
+
* across all sources — use for a single combined grid.
|
|
118
|
+
*/
|
|
119
|
+
mode?: "per-source" | "merged";
|
|
120
|
+
/**
|
|
121
|
+
* In `"merged"` mode, how nodes from different sources are ordered:
|
|
122
|
+
* - `"sequential"` (default): source 1 in full, then source 2, …
|
|
123
|
+
* - `"interleaved"`: round-robin — every source gets equal priority.
|
|
124
|
+
* - `"proportional"`: each source spread by its result count, so larger
|
|
125
|
+
* sources appear more often. Ignored in `"per-source"` mode.
|
|
126
|
+
*/
|
|
127
|
+
mergeOrder?: "sequential" | "interleaved" | "proportional";
|
|
128
|
+
/** Items per page (across all sources in `"merged"` mode; per source otherwise). */
|
|
129
|
+
pageSize: number;
|
|
130
|
+
/** Page sizes offered in the "results per page" control. */
|
|
131
|
+
pageSizeOptions: number[];
|
|
132
|
+
}
|
|
133
|
+
|
|
89
134
|
/** Top-level config passed to {@link useSearch}. */
|
|
90
135
|
export interface SearchConfig {
|
|
91
136
|
sources: SObjectSourceConfig[];
|
|
137
|
+
/**
|
|
138
|
+
* Global pagination config — the single source of truth for page size, page
|
|
139
|
+
* options, mode, and merge order. See {@link SearchPaginationConfig}. When
|
|
140
|
+
* omitted, pagination defaults to per-source mode, page size 10, options
|
|
141
|
+
* `[10, 25, 50]`.
|
|
142
|
+
*/
|
|
143
|
+
pagination?: SearchPaginationConfig;
|
|
92
144
|
}
|
|
93
145
|
|
|
94
146
|
// ---------------------------------------------------------------------------
|
|
@@ -129,6 +181,8 @@ export interface SourceController {
|
|
|
129
181
|
};
|
|
130
182
|
pagination: {
|
|
131
183
|
pageSize: number;
|
|
184
|
+
/** Allowed page sizes — from the global pagination config. */
|
|
185
|
+
pageSizeOptions: readonly number[];
|
|
132
186
|
pageIndex: number;
|
|
133
187
|
hasNextPage: boolean;
|
|
134
188
|
hasPreviousPage: boolean;
|
|
@@ -149,6 +203,70 @@ export type SearchScope = "all" | string;
|
|
|
149
203
|
|
|
150
204
|
export const ALL_SCOPE = "all" as const;
|
|
151
205
|
|
|
206
|
+
/**
|
|
207
|
+
* One result node tagged with the source it came from. Produced by
|
|
208
|
+
* {@link SearchHandle.mergedResults} so a single combined grid can render
|
|
209
|
+
* cross-source results and still pick the right card per node.
|
|
210
|
+
*/
|
|
211
|
+
export interface MergedResultItem {
|
|
212
|
+
/** The `key` of the source this node came from. */
|
|
213
|
+
sourceKey: string;
|
|
214
|
+
/** The raw result node (cast to your typed node in the renderer). */
|
|
215
|
+
node: unknown;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Aggregate pagination across every in-scope source, exposed by
|
|
220
|
+
* {@link SearchHandle.globalPagination}. Its exact behaviour depends on the
|
|
221
|
+
* `pagination` mode passed to {@link useSearch}:
|
|
222
|
+
*
|
|
223
|
+
* - **`"merged"`** (recommended for a single combined grid): the cumulative
|
|
224
|
+
* result set is treated as one list and windowed into fixed pages of
|
|
225
|
+
* `pageSize`. Page `P` always shows **exactly** `pageSize` items (the last
|
|
226
|
+
* page may show fewer), `pageCount` is `ceil(totalCount / pageSize)`, and
|
|
227
|
+
* `goToPage` can jump to any page. Under the hood each in-scope source fetches
|
|
228
|
+
* `(P + 1) * pageSize` rows from its front, the results are concatenated in
|
|
229
|
+
* config order, and the page-`P` slice is taken — so the math is exact, not an
|
|
230
|
+
* estimate.
|
|
231
|
+
* - **`"per-source"`** (default): each source paginates with its own cursor and
|
|
232
|
+
* a "global page" is the furthest-advanced in-scope source's page. `Next`
|
|
233
|
+
* advances only the sources still on the current global page that have more
|
|
234
|
+
* data; a source that runs out is left behind. `goToPage` is best-effort
|
|
235
|
+
* (adjacent pages only) because cursors can't seek to an arbitrary offset.
|
|
236
|
+
*/
|
|
237
|
+
export interface GlobalPagination {
|
|
238
|
+
/** Current global page, 0-based. */
|
|
239
|
+
pageIndex: number;
|
|
240
|
+
/**
|
|
241
|
+
* Total number of global pages. In `"merged"` mode this is exactly
|
|
242
|
+
* `ceil(totalCount / pageSize)`. In `"per-source"` mode it is the `max` over
|
|
243
|
+
* in-scope sources of `ceil(totalCount / pageSize)` and may be an estimate if
|
|
244
|
+
* a source omits `totalCount`. `0` when there are no results, otherwise `>= 1`.
|
|
245
|
+
*/
|
|
246
|
+
pageCount: number;
|
|
247
|
+
/** Shared page size. */
|
|
248
|
+
pageSize: number;
|
|
249
|
+
/** Allowed page sizes (from the first in-scope source). */
|
|
250
|
+
pageSizeOptions: readonly number[];
|
|
251
|
+
/** True when there is a page after the current one. */
|
|
252
|
+
hasNextPage: boolean;
|
|
253
|
+
/** True when the current global page is past the first. */
|
|
254
|
+
hasPreviousPage: boolean;
|
|
255
|
+
/** Cumulative result count summed across in-scope sources. */
|
|
256
|
+
totalCount: number;
|
|
257
|
+
/** Advance to the next global page. */
|
|
258
|
+
goToNextPage: () => void;
|
|
259
|
+
/** Step back to the previous global page. */
|
|
260
|
+
goToPreviousPage: () => void;
|
|
261
|
+
/**
|
|
262
|
+
* Jump to a specific 0-based page. Exact in `"merged"` mode; in
|
|
263
|
+
* `"per-source"` mode only adjacent pages are honoured (others are a no-op).
|
|
264
|
+
*/
|
|
265
|
+
goToPage: (pageIndex: number) => void;
|
|
266
|
+
/** Set the page size (resets to the first page). */
|
|
267
|
+
setPageSize: (size: number) => void;
|
|
268
|
+
}
|
|
269
|
+
|
|
152
270
|
export interface SearchHandle {
|
|
153
271
|
q: string;
|
|
154
272
|
setQ: (q: string) => void;
|
|
@@ -160,6 +278,26 @@ export interface SearchHandle {
|
|
|
160
278
|
*/
|
|
161
279
|
scopeLocked: boolean;
|
|
162
280
|
sources: Record<string, SourceController>;
|
|
281
|
+
/**
|
|
282
|
+
* Source controllers that participate in the current scope, in config
|
|
283
|
+
* declaration order. In `"all"` scope this is every source; otherwise just
|
|
284
|
+
* the selected one. Convenience for callers building aggregate UI.
|
|
285
|
+
*/
|
|
286
|
+
inScopeSources: SourceController[];
|
|
287
|
+
/**
|
|
288
|
+
* In-scope nodes flattened into one list (config order, source by source),
|
|
289
|
+
* each tagged with its `sourceKey` — for rendering a single combined grid
|
|
290
|
+
* instead of one section per source. In `"merged"` pagination mode this is
|
|
291
|
+
* exactly the current page's window (at most `globalPagination.pageSize`
|
|
292
|
+
* items); in `"per-source"` mode it is every in-scope source's current page
|
|
293
|
+
* concatenated.
|
|
294
|
+
*/
|
|
295
|
+
mergedResults: MergedResultItem[];
|
|
296
|
+
/**
|
|
297
|
+
* Aggregate pagination across all in-scope sources — drive one shared
|
|
298
|
+
* pager over the cumulative result set. See {@link GlobalPagination}.
|
|
299
|
+
*/
|
|
300
|
+
globalPagination: GlobalPagination;
|
|
163
301
|
loading: boolean;
|
|
164
302
|
error: string | null;
|
|
165
303
|
resetAll: () => void;
|
|
@@ -28,6 +28,17 @@ export type FilterFieldType =
|
|
|
28
28
|
| "datetimerange"
|
|
29
29
|
| "multipicklist";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Which comparison UI a date/datetime filter exposes:
|
|
33
|
+
* - `"range"`: a single Between control (two date inputs, lower–upper bound).
|
|
34
|
+
* - `"comparison"`: an After / Before selector with one date input.
|
|
35
|
+
* - `"both"`: a Between / After / Before selector that switches between the two.
|
|
36
|
+
*
|
|
37
|
+
* Applies only to the date-family `type`s (`date`, `daterange`, `datetime`,
|
|
38
|
+
* `datetimerange`); ignored for other field types. Defaults to `"comparison"`.
|
|
39
|
+
*/
|
|
40
|
+
export type DateFilterMode = "range" | "comparison" | "both";
|
|
41
|
+
|
|
31
42
|
export type FilterFieldConfig<TFieldName extends string = string> = {
|
|
32
43
|
field: TFieldName;
|
|
33
44
|
label: string;
|
|
@@ -35,6 +46,8 @@ export type FilterFieldConfig<TFieldName extends string = string> = {
|
|
|
35
46
|
placeholder?: string;
|
|
36
47
|
/** Required for picklist / multipicklist. */
|
|
37
48
|
options?: Array<{ value: string; label: string }>;
|
|
49
|
+
/** Date-family only — see {@link DateFilterMode}. Defaults to `"comparison"`. */
|
|
50
|
+
dateMode?: DateFilterMode;
|
|
38
51
|
helpText?: string;
|
|
39
52
|
};
|
|
40
53
|
|
package/dist/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/webapp-template-base-sfdx-project-experimental",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.11.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@salesforce/webapp-template-base-sfdx-project-experimental",
|
|
9
|
-
"version": "11.
|
|
9
|
+
"version": "11.11.0",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@lwc/eslint-plugin-lwc": "^3.3.0",
|
package/dist/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/ui-bundle-template-feature-react-search",
|
|
3
|
-
"version": "11.
|
|
4
|
-
"description": "Configuration-driven multi-source search for UI Bundles",
|
|
3
|
+
"version": "11.11.0",
|
|
4
|
+
"description": "[Beta] Configuration-driven multi-source search for UI Bundles",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"author": "",
|
|
7
7
|
"type": "module",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
3
|
+
|
|
4
|
+
function Popover({}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
5
|
+
return <></>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function PopoverTrigger({}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
9
|
+
return <></>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function PopoverContent({}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
13
|
+
return <></>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function PopoverAnchor({}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
17
|
+
return <></>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function PopoverHeader({}: React.ComponentProps<"div">) {
|
|
21
|
+
return <></>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function PopoverTitle({}: React.ComponentProps<"h2">) {
|
|
25
|
+
return <></>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function PopoverDescription({}: React.ComponentProps<"p">) {
|
|
29
|
+
return <></>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
Popover,
|
|
34
|
+
PopoverAnchor,
|
|
35
|
+
PopoverContent,
|
|
36
|
+
PopoverDescription,
|
|
37
|
+
PopoverHeader,
|
|
38
|
+
PopoverTitle,
|
|
39
|
+
PopoverTrigger,
|
|
40
|
+
};
|
|
@@ -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/__inherit__alert";
|
|
37
|
+
import { Skeleton } from "../../../components/ui/__inherit__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
|