@salesforce/webapp-template-app-react-sample-b2e-experimental 1.116.13 → 1.117.1
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/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/webapplications/propertymanagementapp/package.json +3 -3
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/components/layout/FilterRow.tsx +1 -1
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +15 -6
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/FilterContext.tsx +1 -32
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/BooleanFilter.tsx +9 -5
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/DateFilter.tsx +15 -8
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/DateRangeFilter.tsx +8 -7
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +33 -0
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +4 -5
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +113 -82
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/SearchFilter.tsx +24 -11
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/SelectFilter.tsx +9 -5
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/TextFilter.tsx +29 -12
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/hooks/useDebouncedCallback.ts +34 -0
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/hooks/useObjectSearchParams.ts +5 -86
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/utils/debounce.ts +4 -1
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/utils/filterUtils.ts +24 -1
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/ApplicationSearch.tsx +13 -18
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/MaintenanceRequestSearch.tsx +13 -18
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/MaintenanceWorkerSearch.tsx +11 -18
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/PropertySearch.tsx +6 -15
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/dist/force-app/main/default/webapplications/propertymanagementapp/src/lib/filterUtils.ts +0 -11
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
|
+
## [1.117.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.117.0...v1.117.1) (2026-03-28)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# [1.117.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.13...v1.117.0) (2026-03-27)
|
|
15
|
+
|
|
16
|
+
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
## [1.116.13](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.12...v1.116.13) (2026-03-27)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"graphql:schema": "node scripts/get-graphql-schema.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@salesforce/sdk-data": "^1.
|
|
19
|
-
"@salesforce/webapp-experimental": "^1.
|
|
18
|
+
"@salesforce/sdk-data": "^1.117.1",
|
|
19
|
+
"@salesforce/webapp-experimental": "^1.117.1",
|
|
20
20
|
"@tailwindcss/vite": "^4.1.17",
|
|
21
21
|
"class-variance-authority": "^0.7.1",
|
|
22
22
|
"clsx": "^2.1.1",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
44
44
|
"@graphql-tools/utils": "^11.0.0",
|
|
45
45
|
"@playwright/test": "^1.49.0",
|
|
46
|
-
"@salesforce/vite-plugin-webapp-experimental": "^1.
|
|
46
|
+
"@salesforce/vite-plugin-webapp-experimental": "^1.117.1",
|
|
47
47
|
"@testing-library/jest-dom": "^6.6.3",
|
|
48
48
|
"@testing-library/react": "^16.1.0",
|
|
49
49
|
"@testing-library/user-event": "^14.5.2",
|
|
@@ -12,7 +12,7 @@ export function FilterRow({
|
|
|
12
12
|
}: FilterRowProps) {
|
|
13
13
|
return (
|
|
14
14
|
<div
|
|
15
|
-
className={cn("bg-white rounded-lg shadow-sm border border-gray-200 p-4", className)}
|
|
15
|
+
className={cn("bg-white rounded-lg shadow-sm border border-gray-200 p-4 pb-2", className)}
|
|
16
16
|
role="region"
|
|
17
17
|
aria-label={ariaLabel}
|
|
18
18
|
{...props}
|
|
@@ -62,8 +62,8 @@ const FILTER_CONFIGS: FilterFieldConfig[] = [
|
|
|
62
62
|
{ field: "Industry", label: "Industry", type: "picklist" },
|
|
63
63
|
{ field: "Type", label: "Type", type: "multipicklist" },
|
|
64
64
|
{ field: "AnnualRevenue", label: "Annual Revenue", type: "numeric" },
|
|
65
|
-
{ field: "CreatedDate", label: "Created Date", type: "
|
|
66
|
-
{ field: "LastModifiedDate", label: "Last Modified Date", type: "
|
|
65
|
+
{ field: "CreatedDate", label: "Created Date", type: "datetime" },
|
|
66
|
+
{ field: "LastModifiedDate", label: "Last Modified Date", type: "datetimerange" },
|
|
67
67
|
];
|
|
68
68
|
|
|
69
69
|
const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
|
|
@@ -152,7 +152,7 @@ export default function AccountSearch() {
|
|
|
152
152
|
</div>
|
|
153
153
|
</CardHeader>
|
|
154
154
|
<CollapsibleContent>
|
|
155
|
-
<CardContent className="space-y-
|
|
155
|
+
<CardContent className="space-y-1 pt-0">
|
|
156
156
|
<SearchFilter
|
|
157
157
|
field="search"
|
|
158
158
|
label="Search"
|
|
@@ -165,9 +165,18 @@ export default function AccountSearch() {
|
|
|
165
165
|
options={industryOptions ?? []}
|
|
166
166
|
/>
|
|
167
167
|
<MultiSelectFilter field="Type" label="Type" options={typeOptions ?? []} />
|
|
168
|
-
<NumericRangeFilter
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
<NumericRangeFilter
|
|
169
|
+
field="AnnualRevenue"
|
|
170
|
+
label="Annual Revenue"
|
|
171
|
+
min={0}
|
|
172
|
+
max={1_000_000_000_000}
|
|
173
|
+
/>
|
|
174
|
+
<DateFilter field="CreatedDate" label="Created Date" filterType="datetime" />
|
|
175
|
+
<DateRangeFilter
|
|
176
|
+
field="LastModifiedDate"
|
|
177
|
+
label="Last Modified Date"
|
|
178
|
+
filterType="datetimerange"
|
|
179
|
+
/>
|
|
171
180
|
</CardContent>
|
|
172
181
|
</CollapsibleContent>
|
|
173
182
|
</Collapsible>
|
|
@@ -7,9 +7,6 @@ interface FilterContextValue {
|
|
|
7
7
|
onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
|
|
8
8
|
onFilterRemove: (field: string) => void;
|
|
9
9
|
onReset: () => void;
|
|
10
|
-
onApply: () => void;
|
|
11
|
-
hasPendingChanges: boolean;
|
|
12
|
-
hasValidationError: boolean;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
const FilterContext = createContext<FilterContextValue | null>(null);
|
|
@@ -19,9 +16,6 @@ interface FilterProviderProps {
|
|
|
19
16
|
onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
|
|
20
17
|
onFilterRemove: (field: string) => void;
|
|
21
18
|
onReset: () => void;
|
|
22
|
-
onApply?: () => void;
|
|
23
|
-
hasPendingChanges?: boolean;
|
|
24
|
-
hasValidationError?: boolean;
|
|
25
19
|
children: ReactNode;
|
|
26
20
|
}
|
|
27
21
|
|
|
@@ -30,9 +24,6 @@ export function FilterProvider({
|
|
|
30
24
|
onFilterChange,
|
|
31
25
|
onFilterRemove,
|
|
32
26
|
onReset,
|
|
33
|
-
onApply,
|
|
34
|
-
hasPendingChanges = false,
|
|
35
|
-
hasValidationError = false,
|
|
36
27
|
children,
|
|
37
28
|
}: FilterProviderProps) {
|
|
38
29
|
return (
|
|
@@ -42,9 +33,6 @@ export function FilterProvider({
|
|
|
42
33
|
onFilterChange,
|
|
43
34
|
onFilterRemove,
|
|
44
35
|
onReset,
|
|
45
|
-
onApply: onApply ?? (() => {}),
|
|
46
|
-
hasPendingChanges,
|
|
47
|
-
hasValidationError,
|
|
48
36
|
}}
|
|
49
37
|
>
|
|
50
38
|
{children}
|
|
@@ -75,13 +63,10 @@ export function useFilterField(field: string) {
|
|
|
75
63
|
}
|
|
76
64
|
|
|
77
65
|
export function useFilterPanel() {
|
|
78
|
-
const { filters, onReset
|
|
66
|
+
const { filters, onReset } = useFilterContext();
|
|
79
67
|
return {
|
|
80
68
|
hasActiveFilters: filters.length > 0,
|
|
81
|
-
hasPendingChanges,
|
|
82
|
-
hasValidationError,
|
|
83
69
|
resetAll: onReset,
|
|
84
|
-
apply: onApply,
|
|
85
70
|
};
|
|
86
71
|
}
|
|
87
72
|
|
|
@@ -96,19 +81,3 @@ export function FilterResetButton({ children, ...props }: FilterResetButtonProps
|
|
|
96
81
|
</Button>
|
|
97
82
|
);
|
|
98
83
|
}
|
|
99
|
-
|
|
100
|
-
type FilterApplyButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "disabled">;
|
|
101
|
-
|
|
102
|
-
export function FilterApplyButton({ children, ...props }: FilterApplyButtonProps) {
|
|
103
|
-
const { apply, hasPendingChanges, hasValidationError } = useFilterPanel();
|
|
104
|
-
return (
|
|
105
|
-
<Button
|
|
106
|
-
onClick={apply}
|
|
107
|
-
disabled={!hasPendingChanges || hasValidationError}
|
|
108
|
-
aria-label="Apply filters"
|
|
109
|
-
{...props}
|
|
110
|
-
>
|
|
111
|
-
{children ?? "Apply"}
|
|
112
|
-
</Button>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
@@ -5,9 +5,9 @@ import {
|
|
|
5
5
|
SelectTrigger,
|
|
6
6
|
SelectValue,
|
|
7
7
|
} from "../../../../components/ui/select";
|
|
8
|
-
import { Label } from "../../../../components/ui/label";
|
|
9
8
|
import { cn } from "../../../../lib/utils";
|
|
10
9
|
import { useFilterField } from "../FilterContext";
|
|
10
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
11
11
|
import type { ActiveFilterValue } from "../../utils/filterUtils";
|
|
12
12
|
|
|
13
13
|
const ALL_VALUE = "__all__";
|
|
@@ -21,11 +21,15 @@ interface BooleanFilterProps extends Omit<React.ComponentProps<"div">, "onChange
|
|
|
21
21
|
export function BooleanFilter({ field, label, helpText, className, ...props }: BooleanFilterProps) {
|
|
22
22
|
const { value, onChange } = useFilterField(field);
|
|
23
23
|
return (
|
|
24
|
-
<
|
|
25
|
-
|
|
24
|
+
<FilterFieldWrapper
|
|
25
|
+
label={label}
|
|
26
|
+
htmlFor={`filter-${field}`}
|
|
27
|
+
helpText={helpText}
|
|
28
|
+
className={className}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
26
31
|
<BooleanFilterSelect field={field} label={label} value={value} onChange={onChange} />
|
|
27
|
-
|
|
28
|
-
</div>
|
|
32
|
+
</FilterFieldWrapper>
|
|
29
33
|
);
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { parseISO } from "date-fns";
|
|
3
|
-
import { Label } from "../../../../components/ui/label";
|
|
4
3
|
import {
|
|
5
4
|
DatePicker,
|
|
6
5
|
DatePickerTrigger,
|
|
7
6
|
DatePickerContent,
|
|
8
7
|
DatePickerCalendar,
|
|
9
8
|
} from "../../../../components/ui/datePicker";
|
|
10
|
-
|
|
9
|
+
|
|
11
10
|
import { useFilterField } from "../FilterContext";
|
|
11
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
12
|
+
import type { FilterFieldType } from "../../utils/filterUtils";
|
|
12
13
|
import {
|
|
13
14
|
Select,
|
|
14
15
|
SelectContent,
|
|
@@ -32,9 +33,17 @@ interface DateFilterProps extends Omit<React.ComponentProps<"div">, "onChange">
|
|
|
32
33
|
field: string;
|
|
33
34
|
label: string;
|
|
34
35
|
helpText?: string;
|
|
36
|
+
filterType?: FilterFieldType;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export function DateFilter({
|
|
39
|
+
export function DateFilter({
|
|
40
|
+
field,
|
|
41
|
+
label,
|
|
42
|
+
helpText,
|
|
43
|
+
filterType = "date",
|
|
44
|
+
className,
|
|
45
|
+
...props
|
|
46
|
+
}: DateFilterProps) {
|
|
38
47
|
const { value, onChange } = useFilterField(field);
|
|
39
48
|
|
|
40
49
|
const initialOp: DateOperator = value?.min ? "gt" : "lt";
|
|
@@ -63,7 +72,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
|
|
|
63
72
|
onChange({
|
|
64
73
|
field,
|
|
65
74
|
label,
|
|
66
|
-
type:
|
|
75
|
+
type: filterType,
|
|
67
76
|
value: op,
|
|
68
77
|
min: f === "min" ? dateStr : undefined,
|
|
69
78
|
max: f === "max" ? dateStr : undefined,
|
|
@@ -71,8 +80,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
|
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
return (
|
|
74
|
-
<
|
|
75
|
-
<Label>{label}</Label>
|
|
83
|
+
<FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
|
|
76
84
|
<div className="flex gap-2">
|
|
77
85
|
<Select value={operator} onValueChange={(v) => handleOperatorChange(v as DateOperator)}>
|
|
78
86
|
<SelectTrigger className="w-full flex-1">
|
|
@@ -104,8 +112,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
|
|
|
104
112
|
</DatePickerContent>
|
|
105
113
|
</DatePicker>
|
|
106
114
|
</div>
|
|
107
|
-
|
|
108
|
-
</div>
|
|
115
|
+
</FilterFieldWrapper>
|
|
109
116
|
);
|
|
110
117
|
}
|
|
111
118
|
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import type { DateRange } from "react-day-picker";
|
|
2
|
-
import { Label } from "../../../../components/ui/label";
|
|
3
2
|
import {
|
|
4
3
|
DatePicker,
|
|
5
4
|
DatePickerRangeTrigger,
|
|
6
5
|
DatePickerContent,
|
|
7
6
|
DatePickerCalendar,
|
|
8
7
|
} from "../../../../components/ui/datePicker";
|
|
9
|
-
|
|
8
|
+
|
|
10
9
|
import { useFilterField } from "../FilterContext";
|
|
10
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
11
|
+
import type { FilterFieldType } from "../../utils/filterUtils";
|
|
11
12
|
import { toDate, toDateString } from "./DateFilter";
|
|
12
13
|
|
|
13
14
|
interface DateRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
14
15
|
field: string;
|
|
15
16
|
label: string;
|
|
16
17
|
helpText?: string;
|
|
18
|
+
filterType?: FilterFieldType;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export function DateRangeFilter({
|
|
20
22
|
field,
|
|
21
23
|
label,
|
|
22
24
|
helpText,
|
|
25
|
+
filterType = "daterange",
|
|
23
26
|
className,
|
|
24
27
|
...props
|
|
25
28
|
}: DateRangeFilterProps) {
|
|
@@ -35,7 +38,7 @@ export function DateRangeFilter({
|
|
|
35
38
|
onChange({
|
|
36
39
|
field,
|
|
37
40
|
label,
|
|
38
|
-
type:
|
|
41
|
+
type: filterType,
|
|
39
42
|
min: toDateString(range?.from),
|
|
40
43
|
max: toDateString(range?.to),
|
|
41
44
|
});
|
|
@@ -43,8 +46,7 @@ export function DateRangeFilter({
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
return (
|
|
46
|
-
<
|
|
47
|
-
<Label>{label}</Label>
|
|
49
|
+
<FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
|
|
48
50
|
<DatePicker>
|
|
49
51
|
<DatePickerRangeTrigger
|
|
50
52
|
className="w-full"
|
|
@@ -63,7 +65,6 @@ export function DateRangeFilter({
|
|
|
63
65
|
/>
|
|
64
66
|
</DatePickerContent>
|
|
65
67
|
</DatePicker>
|
|
66
|
-
|
|
67
|
-
</div>
|
|
68
|
+
</FilterFieldWrapper>
|
|
68
69
|
);
|
|
69
70
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Label } from "../../../../components/ui/label";
|
|
2
|
+
import { cn } from "../../../../lib/utils";
|
|
3
|
+
|
|
4
|
+
interface FilterFieldWrapperProps extends React.ComponentProps<"div"> {
|
|
5
|
+
label: string;
|
|
6
|
+
htmlFor?: string;
|
|
7
|
+
helpText?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FilterFieldWrapper({
|
|
12
|
+
label,
|
|
13
|
+
htmlFor,
|
|
14
|
+
helpText,
|
|
15
|
+
error,
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
...props
|
|
19
|
+
}: FilterFieldWrapperProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn("space-y-1", className)} {...props}>
|
|
22
|
+
<Label htmlFor={htmlFor}>{label}</Label>
|
|
23
|
+
{children}
|
|
24
|
+
<div className="min-h-4">
|
|
25
|
+
{error ? (
|
|
26
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
27
|
+
) : (
|
|
28
|
+
helpText && <p className="text-xs text-muted-foreground">{helpText}</p>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -4,11 +4,12 @@ import {
|
|
|
4
4
|
PopoverTrigger,
|
|
5
5
|
} from "../../../../components/ui/popover";
|
|
6
6
|
import { Checkbox } from "../../../../components/ui/checkbox";
|
|
7
|
-
import { Label } from "../../../../components/ui/label";
|
|
8
7
|
import { Button } from "../../../../components/ui/button";
|
|
9
8
|
import { cn } from "../../../../lib/utils";
|
|
9
|
+
import { Label } from "../../../../components/ui/label";
|
|
10
10
|
import { ChevronDown } from "lucide-react";
|
|
11
11
|
import { useFilterField } from "../FilterContext";
|
|
12
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
12
13
|
|
|
13
14
|
interface MultiSelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
14
15
|
field: string;
|
|
@@ -53,8 +54,7 @@ export function MultiSelectFilter({
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
return (
|
|
56
|
-
<
|
|
57
|
-
<Label>{label}</Label>
|
|
57
|
+
<FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
|
|
58
58
|
<Popover>
|
|
59
59
|
<PopoverTrigger asChild>
|
|
60
60
|
<Button
|
|
@@ -92,7 +92,6 @@ export function MultiSelectFilter({
|
|
|
92
92
|
</div>
|
|
93
93
|
</PopoverContent>
|
|
94
94
|
</Popover>
|
|
95
|
-
|
|
96
|
-
</div>
|
|
95
|
+
</FilterFieldWrapper>
|
|
97
96
|
);
|
|
98
97
|
}
|
|
@@ -1,49 +1,52 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
1
2
|
import { Input } from "../../../../components/ui/input";
|
|
2
|
-
|
|
3
|
-
import { toast } from "sonner";
|
|
4
|
-
import { cn } from "../../../../lib/utils";
|
|
3
|
+
|
|
5
4
|
import { useFilterField } from "../FilterContext";
|
|
5
|
+
import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
|
|
6
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
6
7
|
import type { ActiveFilterValue } from "../../utils/filterUtils";
|
|
7
8
|
|
|
8
9
|
interface NumericRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
9
10
|
field: string;
|
|
10
11
|
label: string;
|
|
11
12
|
helpText?: string;
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
min?: number;
|
|
14
|
+
max?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function NumericRangeFilter({
|
|
17
18
|
field,
|
|
18
19
|
label,
|
|
19
20
|
helpText,
|
|
21
|
+
min,
|
|
22
|
+
max,
|
|
20
23
|
className,
|
|
21
|
-
minInputProps,
|
|
22
|
-
maxInputProps,
|
|
23
24
|
...props
|
|
24
25
|
}: NumericRangeFilterProps) {
|
|
25
26
|
const { value, onChange } = useFilterField(field);
|
|
26
27
|
return (
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
</div>
|
|
28
|
+
<NumericRangeFilterInputs
|
|
29
|
+
field={field}
|
|
30
|
+
label={label}
|
|
31
|
+
helpText={helpText}
|
|
32
|
+
value={value}
|
|
33
|
+
onChange={onChange}
|
|
34
|
+
min={min}
|
|
35
|
+
max={max}
|
|
36
|
+
className={className}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
43
43
|
field: string;
|
|
44
44
|
label: string;
|
|
45
|
+
helpText?: string;
|
|
45
46
|
value: ActiveFilterValue | undefined;
|
|
46
47
|
onChange: (value: ActiveFilterValue | undefined) => void;
|
|
48
|
+
min?: number;
|
|
49
|
+
max?: number;
|
|
47
50
|
minInputProps?: React.ComponentProps<typeof Input>;
|
|
48
51
|
maxInputProps?: React.ComponentProps<typeof Input>;
|
|
49
52
|
}
|
|
@@ -51,82 +54,110 @@ interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">
|
|
|
51
54
|
export function NumericRangeFilterInputs({
|
|
52
55
|
field,
|
|
53
56
|
label,
|
|
57
|
+
helpText,
|
|
54
58
|
value,
|
|
55
59
|
onChange,
|
|
60
|
+
min: boundMin,
|
|
61
|
+
max: boundMax,
|
|
56
62
|
className,
|
|
57
|
-
minInputProps,
|
|
58
|
-
maxInputProps,
|
|
59
63
|
...props
|
|
60
64
|
}: NumericRangeFilterInputsProps) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const min = filter.min?.trim();
|
|
65
|
-
const max = filter.max?.trim();
|
|
66
|
-
const filterLabel = filter.label || filter.field;
|
|
65
|
+
const [localMin, setLocalMin] = useState(value?.min ?? "");
|
|
66
|
+
const [localMax, setLocalMax] = useState(value?.max ?? "");
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
const externalMin = value?.min ?? "";
|
|
69
|
+
const externalMax = value?.max ?? "";
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
setLocalMin(externalMin);
|
|
72
|
+
}, [externalMin]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setLocalMax(externalMax);
|
|
75
|
+
}, [externalMax]);
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return null;
|
|
77
|
+
const isOutOfBounds = (v: string) => {
|
|
78
|
+
if (v === "") return false;
|
|
79
|
+
const n = Number(v);
|
|
80
|
+
return (boundMin != null && n < boundMin) || (boundMax != null && n > boundMax);
|
|
77
81
|
};
|
|
82
|
+
const minOutOfBounds = isOutOfBounds(localMin);
|
|
83
|
+
const maxOutOfBounds = isOutOfBounds(localMax);
|
|
84
|
+
const isRangeInverted = localMin !== "" && localMax !== "" && Number(localMin) > Number(localMax);
|
|
85
|
+
const hasError = minOutOfBounds || maxOutOfBounds || isRangeInverted;
|
|
78
86
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
field,
|
|
82
|
-
label,
|
|
83
|
-
type: "numeric" as const,
|
|
84
|
-
min: value?.min ?? "",
|
|
85
|
-
max: value?.max ?? "",
|
|
86
|
-
[bound]: v,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
if (!next.min && !next.max) {
|
|
87
|
+
const debouncedOnChange = useDebouncedCallback((min: string, max: string) => {
|
|
88
|
+
if (!min && !max) {
|
|
90
89
|
onChange(undefined);
|
|
91
|
-
|
|
92
|
-
onChange(next);
|
|
90
|
+
return;
|
|
93
91
|
}
|
|
94
|
-
|
|
92
|
+
const minNum = min !== "" ? Number(min) : null;
|
|
93
|
+
const maxNum = max !== "" ? Number(max) : null;
|
|
94
|
+
if (minNum != null && maxNum != null && minNum > maxNum) return;
|
|
95
|
+
if (
|
|
96
|
+
minNum != null &&
|
|
97
|
+
((boundMin != null && minNum < boundMin) || (boundMax != null && minNum > boundMax))
|
|
98
|
+
)
|
|
99
|
+
return;
|
|
100
|
+
if (
|
|
101
|
+
maxNum != null &&
|
|
102
|
+
((boundMin != null && maxNum < boundMin) || (boundMax != null && maxNum > boundMax))
|
|
103
|
+
)
|
|
104
|
+
return;
|
|
105
|
+
onChange({ field, label, type: "numeric" as const, min, max });
|
|
106
|
+
});
|
|
95
107
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
const boundsLabel =
|
|
109
|
+
boundMin != null && boundMax != null
|
|
110
|
+
? `${boundMin}–${boundMax}`
|
|
111
|
+
: boundMin != null
|
|
112
|
+
? `${boundMin} or more`
|
|
113
|
+
: boundMax != null
|
|
114
|
+
? `${boundMax} or less`
|
|
115
|
+
: null;
|
|
116
|
+
|
|
117
|
+
const errorMessage = isRangeInverted
|
|
118
|
+
? "Min must not exceed max"
|
|
119
|
+
: (minOutOfBounds || maxOutOfBounds) && boundsLabel
|
|
120
|
+
? `Value must be between ${boundsLabel}`
|
|
121
|
+
: undefined;
|
|
109
122
|
|
|
110
123
|
return (
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
<FilterFieldWrapper
|
|
125
|
+
label={label}
|
|
126
|
+
helpText={helpText}
|
|
127
|
+
error={errorMessage}
|
|
128
|
+
className={className}
|
|
129
|
+
{...props}
|
|
130
|
+
>
|
|
131
|
+
<div className="flex gap-2">
|
|
132
|
+
<Input
|
|
133
|
+
type="number"
|
|
134
|
+
placeholder="Min"
|
|
135
|
+
value={localMin}
|
|
136
|
+
min={boundMin}
|
|
137
|
+
max={boundMax}
|
|
138
|
+
onChange={(e) => {
|
|
139
|
+
const v = e.target.value;
|
|
140
|
+
setLocalMin(v);
|
|
141
|
+
debouncedOnChange(v, localMax);
|
|
142
|
+
}}
|
|
143
|
+
aria-label={`${label} minimum`}
|
|
144
|
+
aria-invalid={hasError || undefined}
|
|
145
|
+
/>
|
|
146
|
+
<Input
|
|
147
|
+
type="number"
|
|
148
|
+
placeholder="Max"
|
|
149
|
+
value={localMax}
|
|
150
|
+
min={boundMin}
|
|
151
|
+
max={boundMax}
|
|
152
|
+
onChange={(e) => {
|
|
153
|
+
const v = e.target.value;
|
|
154
|
+
setLocalMax(v);
|
|
155
|
+
debouncedOnChange(localMin, v);
|
|
156
|
+
}}
|
|
157
|
+
aria-label={`${label} maximum`}
|
|
158
|
+
aria-invalid={hasError || undefined}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</FilterFieldWrapper>
|
|
131
162
|
);
|
|
132
163
|
}
|