@salesforce/ui-bundle-template-feature-react-search 11.10.1 → 11.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +435 -93
- package/dist/CHANGELOG.md +8 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
- package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__popover.tsx +40 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/MergedSearchResults.tsx +263 -0
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +40 -15
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +2 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +12 -13
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +75 -5
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +5 -2
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +128 -18
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +59 -18
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +15 -14
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +287 -26
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +11 -1
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +1 -1
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +146 -8
- package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +13 -0
|
@@ -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/__inherit__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:
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { Input } from "../../../../../components/ui/__inherit__input";
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "../../../../../components/ui/__inherit__select";
|
|
2
10
|
import { useFilterField } from "../FilterContext";
|
|
3
11
|
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
4
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
ActiveFilterValue,
|
|
14
|
+
DateFilterMode,
|
|
15
|
+
FilterFieldType,
|
|
16
|
+
} from "../../../utils/filterUtils";
|
|
5
17
|
|
|
6
18
|
interface DateRangeFilterProps {
|
|
7
19
|
field: string;
|
|
@@ -9,41 +21,139 @@ interface DateRangeFilterProps {
|
|
|
9
21
|
helpText?: string;
|
|
10
22
|
/** "daterange" (Date scalar) or "datetimerange" (DateTime scalar). */
|
|
11
23
|
filterType?: Extract<FilterFieldType, "daterange" | "datetimerange">;
|
|
24
|
+
/**
|
|
25
|
+
* Which comparison chrome to show. See {@link DateFilterMode}. Defaults to
|
|
26
|
+
* `"comparison"` (an After / Before selector with one date input).
|
|
27
|
+
*/
|
|
28
|
+
mode?: DateFilterMode;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
32
|
+
* The three ways to constrain a date field. They map directly onto the
|
|
33
|
+
* `min`/`max` bounds the query builder already understands:
|
|
34
|
+
* - after → only `min` set → `gte`
|
|
35
|
+
* - before → only `max` set → `lte`
|
|
36
|
+
* - between → both set → `gte` AND `lte`
|
|
37
|
+
*/
|
|
38
|
+
type DateOperator = "between" | "after" | "before";
|
|
39
|
+
|
|
40
|
+
const OPERATOR_LABELS: Record<DateOperator, string> = {
|
|
41
|
+
between: "Between",
|
|
42
|
+
after: "After",
|
|
43
|
+
before: "Before",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The operators offered for each {@link DateFilterMode}. */
|
|
47
|
+
const MODE_OPERATORS: Record<DateFilterMode, DateOperator[]> = {
|
|
48
|
+
range: ["between"],
|
|
49
|
+
comparison: ["after", "before"],
|
|
50
|
+
both: ["between", "after", "before"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Pick the operator implied by an already-active filter (e.g. restored from the URL). */
|
|
54
|
+
function operatorFor(value: ActiveFilterValue | undefined): DateOperator {
|
|
55
|
+
const hasMin = !!value?.min;
|
|
56
|
+
const hasMax = !!value?.max;
|
|
57
|
+
if (hasMax && !hasMin) return "before";
|
|
58
|
+
if (hasMin && !hasMax) return "after";
|
|
59
|
+
return "between";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A date constraint whose chrome is driven by `mode`:
|
|
64
|
+
* - `"range"`: a single Between control (two date inputs).
|
|
65
|
+
* - `"comparison"` (default): an After / Before selector with one input.
|
|
66
|
+
* - `"both"`: a Between / After / Before selector spanning the two.
|
|
67
|
+
*
|
|
68
|
+
* "Between" shows two `<input type="date">` controls; "After" / "Before"
|
|
69
|
+
* collapse to one bound to the relevant end. Uses the platform-native picker —
|
|
70
|
+
* no popover/calendar dependency.
|
|
17
71
|
*/
|
|
18
72
|
export function DateRangeFilter({
|
|
19
73
|
field,
|
|
20
74
|
label,
|
|
21
75
|
helpText,
|
|
22
76
|
filterType = "daterange",
|
|
77
|
+
mode = "comparison",
|
|
23
78
|
}: DateRangeFilterProps) {
|
|
24
79
|
const { value, onChange } = useFilterField(field);
|
|
80
|
+
const operators = MODE_OPERATORS[mode];
|
|
81
|
+
|
|
82
|
+
// Operator is local UI state: it must survive a moment where no date is yet
|
|
83
|
+
// entered (which clears the active filter), so it can't be derived from
|
|
84
|
+
// `value` on every render. Seed it from any filter restored on mount, clamped
|
|
85
|
+
// to an operator this mode actually offers.
|
|
86
|
+
const [operator, setOperator] = useState<DateOperator>(() => {
|
|
87
|
+
const restored = operatorFor(value);
|
|
88
|
+
return operators.includes(restored) ? restored : operators[0];
|
|
89
|
+
});
|
|
25
90
|
|
|
26
|
-
const
|
|
91
|
+
const emit = (nextMin: string | undefined, nextMax: string | undefined) => {
|
|
27
92
|
if (!nextMin && !nextMax) onChange(undefined);
|
|
28
93
|
else onChange({ field, label, type: filterType, min: nextMin, max: nextMax });
|
|
29
94
|
};
|
|
30
95
|
|
|
96
|
+
const changeOperator = (next: DateOperator) => {
|
|
97
|
+
setOperator(next);
|
|
98
|
+
// Re-emit, keeping only the bound(s) the new operator uses.
|
|
99
|
+
if (next === "after") emit(value?.min, undefined);
|
|
100
|
+
else if (next === "before") emit(undefined, value?.max);
|
|
101
|
+
else emit(value?.min, value?.max);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const operatorId = `filter-${field}-op`;
|
|
105
|
+
// A lone "between" operator (mode="range") needs no selector — it can't change.
|
|
106
|
+
const showSelector = operators.length > 1;
|
|
107
|
+
|
|
31
108
|
return (
|
|
32
109
|
<FilterFieldWrapper label={label} helpText={helpText}>
|
|
33
|
-
<div className="flex
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
110
|
+
<div className="flex flex-col gap-2">
|
|
111
|
+
{showSelector && (
|
|
112
|
+
<Select value={operator} onValueChange={(v) => changeOperator(v as DateOperator)}>
|
|
113
|
+
<SelectTrigger id={operatorId} className="w-full" aria-label={`${label} comparison`}>
|
|
114
|
+
<SelectValue />
|
|
115
|
+
</SelectTrigger>
|
|
116
|
+
<SelectContent>
|
|
117
|
+
{operators.map((op) => (
|
|
118
|
+
<SelectItem key={op} value={op}>
|
|
119
|
+
{OPERATOR_LABELS[op]}
|
|
120
|
+
</SelectItem>
|
|
121
|
+
))}
|
|
122
|
+
</SelectContent>
|
|
123
|
+
</Select>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{operator === "between" ? (
|
|
127
|
+
<div className="flex items-center gap-2">
|
|
128
|
+
<Input
|
|
129
|
+
type="date"
|
|
130
|
+
value={value?.min ?? ""}
|
|
131
|
+
onChange={(e) => emit(e.target.value || undefined, value?.max)}
|
|
132
|
+
aria-label={`${label} from`}
|
|
133
|
+
/>
|
|
134
|
+
<span className="text-sm text-muted-foreground">–</span>
|
|
135
|
+
<Input
|
|
136
|
+
type="date"
|
|
137
|
+
value={value?.max ?? ""}
|
|
138
|
+
onChange={(e) => emit(value?.min, e.target.value || undefined)}
|
|
139
|
+
aria-label={`${label} to`}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
) : operator === "after" ? (
|
|
143
|
+
<Input
|
|
144
|
+
type="date"
|
|
145
|
+
value={value?.min ?? ""}
|
|
146
|
+
onChange={(e) => emit(e.target.value || undefined, undefined)}
|
|
147
|
+
aria-label={`${label} after`}
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
<Input
|
|
151
|
+
type="date"
|
|
152
|
+
value={value?.max ?? ""}
|
|
153
|
+
onChange={(e) => emit(undefined, e.target.value || undefined)}
|
|
154
|
+
aria-label={`${label} before`}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
47
157
|
</div>
|
|
48
158
|
</FilterFieldWrapper>
|
|
49
159
|
);
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react";
|
|
2
|
+
import { Button } from "../../../../../components/ui/__inherit__button";
|
|
1
3
|
import { Checkbox } from "../../../../../components/ui/__inherit__checkbox";
|
|
2
4
|
import { Label } from "../../../../../components/ui/__inherit__label";
|
|
5
|
+
import {
|
|
6
|
+
Popover,
|
|
7
|
+
PopoverContent,
|
|
8
|
+
PopoverTrigger,
|
|
9
|
+
} from "../../../../../components/ui/__inherit__popover";
|
|
3
10
|
import { useFilterField } from "../FilterContext";
|
|
4
11
|
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
5
12
|
|
|
@@ -23,25 +30,59 @@ export function MultiSelectFilter({ field, label, options, helpText }: MultiSele
|
|
|
23
30
|
else onChange({ field, label, type: "multipicklist", value: joined });
|
|
24
31
|
};
|
|
25
32
|
|
|
33
|
+
// Summarize the selection on the collapsed trigger: the sole label when one is
|
|
34
|
+
// picked, a count when several are, and a placeholder when none.
|
|
35
|
+
const summary =
|
|
36
|
+
selected.size === 0
|
|
37
|
+
? `Select ${label.toLowerCase()}`
|
|
38
|
+
: selected.size === 1
|
|
39
|
+
? (options.find((o) => selected.has(o.value))?.label ?? `${selected.size} selected`)
|
|
40
|
+
: `${selected.size} selected`;
|
|
41
|
+
const triggerId = `filter-${field}`;
|
|
42
|
+
|
|
26
43
|
return (
|
|
27
|
-
<FilterFieldWrapper label={label} helpText={helpText}>
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
<FilterFieldWrapper label={label} htmlFor={triggerId} helpText={helpText}>
|
|
45
|
+
<Popover>
|
|
46
|
+
<PopoverTrigger asChild>
|
|
47
|
+
<Button
|
|
48
|
+
id={triggerId}
|
|
49
|
+
variant="outline"
|
|
50
|
+
className="w-full justify-between font-normal"
|
|
51
|
+
aria-label={`${label}: ${summary}`}
|
|
52
|
+
>
|
|
53
|
+
<span className={`truncate ${selected.size === 0 ? "text-muted-foreground" : ""}`}>
|
|
54
|
+
{summary}
|
|
55
|
+
</span>
|
|
56
|
+
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden="true" />
|
|
57
|
+
</Button>
|
|
58
|
+
</PopoverTrigger>
|
|
59
|
+
<PopoverContent
|
|
60
|
+
align="start"
|
|
61
|
+
className="w-(--radix-popover-trigger-width) max-h-72 overflow-y-auto"
|
|
62
|
+
>
|
|
63
|
+
<div role="group" aria-label={label} className="flex flex-col gap-1">
|
|
64
|
+
{options.length === 0 ? (
|
|
65
|
+
<p className="px-1 py-2 text-sm text-muted-foreground">No options available.</p>
|
|
66
|
+
) : (
|
|
67
|
+
options.map((opt) => {
|
|
68
|
+
const id = `filter-${field}-${opt.value}`;
|
|
69
|
+
return (
|
|
70
|
+
<div key={opt.value} className="flex items-center gap-2">
|
|
71
|
+
<Checkbox
|
|
72
|
+
id={id}
|
|
73
|
+
checked={selected.has(opt.value)}
|
|
74
|
+
onCheckedChange={(checked) => toggle(opt.value, checked === true)}
|
|
75
|
+
/>
|
|
76
|
+
<Label htmlFor={id} className="text-sm font-normal cursor-pointer">
|
|
77
|
+
{opt.label}
|
|
78
|
+
</Label>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
})
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</PopoverContent>
|
|
85
|
+
</Popover>
|
|
45
86
|
</FilterFieldWrapper>
|
|
46
87
|
);
|
|
47
88
|
}
|
package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
+
"pagination": {
|
|
3
|
+
"pageSize": 10,
|
|
4
|
+
"pageSizeOptions": [5, 10, 20]
|
|
5
|
+
},
|
|
2
6
|
"sources": [
|
|
3
7
|
{
|
|
4
8
|
"kind": "sobject",
|
|
5
9
|
"key": "accounts",
|
|
6
10
|
"objectName": "Account",
|
|
7
11
|
"label": "Accounts",
|
|
12
|
+
"labelSingular": "Account",
|
|
8
13
|
"routePattern": "/accounts/:id",
|
|
9
14
|
"searchableFields": ["Name", "Phone", "Industry"],
|
|
10
15
|
"displayFields": [
|
|
@@ -15,25 +20,24 @@
|
|
|
15
20
|
"AnnualRevenue",
|
|
16
21
|
{ "name": "Owner", "subfields": ["Name"] }
|
|
17
22
|
],
|
|
18
|
-
"
|
|
23
|
+
"filterBy": [
|
|
19
24
|
{ "field": "Industry", "label": "Industry", "type": "picklist" },
|
|
20
25
|
{ "field": "Type", "label": "Type", "type": "picklist" },
|
|
21
26
|
{ "field": "AnnualRevenue", "label": "Annual Revenue", "type": "numeric" }
|
|
22
27
|
],
|
|
23
|
-
"
|
|
28
|
+
"sortBy": [
|
|
24
29
|
{ "field": "Name", "label": "Name" },
|
|
25
30
|
{ "field": "Industry", "label": "Industry" },
|
|
26
31
|
{ "field": "AnnualRevenue", "label": "Annual Revenue" },
|
|
27
32
|
{ "field": "CreatedDate", "label": "Created Date" }
|
|
28
|
-
]
|
|
29
|
-
"pageSize": 10,
|
|
30
|
-
"validPageSizes": [5, 10, 20]
|
|
33
|
+
]
|
|
31
34
|
},
|
|
32
35
|
{
|
|
33
36
|
"kind": "sobject",
|
|
34
37
|
"key": "contacts",
|
|
35
38
|
"objectName": "Contact",
|
|
36
39
|
"label": "Contacts",
|
|
40
|
+
"labelSingular": "Contact",
|
|
37
41
|
"routePattern": "/contacts/:id",
|
|
38
42
|
"searchableFields": ["Name", "Email", "Phone"],
|
|
39
43
|
"displayFields": [
|
|
@@ -43,18 +47,17 @@
|
|
|
43
47
|
"Phone",
|
|
44
48
|
{ "name": "Account", "subfields": ["Name"] }
|
|
45
49
|
],
|
|
46
|
-
"
|
|
50
|
+
"sortBy": [
|
|
47
51
|
{ "field": "Name", "label": "Name" },
|
|
48
52
|
{ "field": "CreatedDate", "label": "Created Date" }
|
|
49
|
-
]
|
|
50
|
-
"pageSize": 10,
|
|
51
|
-
"validPageSizes": [5, 10, 20]
|
|
53
|
+
]
|
|
52
54
|
},
|
|
53
55
|
{
|
|
54
56
|
"kind": "sobject",
|
|
55
57
|
"key": "opportunities",
|
|
56
58
|
"objectName": "Opportunity",
|
|
57
59
|
"label": "Opportunities",
|
|
60
|
+
"labelSingular": "Opportunity",
|
|
58
61
|
"routePattern": "/opportunities/:id",
|
|
59
62
|
"searchableFields": ["Name"],
|
|
60
63
|
"displayFields": [
|
|
@@ -64,19 +67,17 @@
|
|
|
64
67
|
"CloseDate",
|
|
65
68
|
{ "name": "Account", "subfields": ["Name"] }
|
|
66
69
|
],
|
|
67
|
-
"
|
|
70
|
+
"filterBy": [
|
|
68
71
|
{ "field": "StageName", "label": "Stage", "type": "picklist" },
|
|
69
72
|
{ "field": "Amount", "label": "Amount", "type": "numeric" },
|
|
70
73
|
{ "field": "CloseDate", "label": "Close Date", "type": "daterange" }
|
|
71
74
|
],
|
|
72
|
-
"
|
|
75
|
+
"sortBy": [
|
|
73
76
|
{ "field": "CloseDate", "label": "Close Date" },
|
|
74
77
|
{ "field": "Amount", "label": "Amount" },
|
|
75
78
|
{ "field": "Name", "label": "Name" }
|
|
76
79
|
],
|
|
77
|
-
"defaultSort": { "field": "CloseDate", "direction": "DESC" }
|
|
78
|
-
"pageSize": 10,
|
|
79
|
-
"validPageSizes": [5, 10, 20]
|
|
80
|
+
"defaultSort": { "field": "CloseDate", "direction": "DESC" }
|
|
80
81
|
}
|
|
81
82
|
]
|
|
82
83
|
}
|