@salesforce/webapp-template-app-react-sample-b2x-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/propertyrentalapp/package.json +3 -3
- package/dist/force-app/main/default/webapplications/propertyrentalapp/propertyrentalapp.webapplication-meta.xml +1 -0
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +15 -6
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/FilterContext.tsx +1 -32
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/BooleanFilter.tsx +9 -5
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/DateFilter.tsx +15 -8
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/DateRangeFilter.tsx +8 -7
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +33 -0
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +4 -5
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +113 -82
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/SearchFilter.tsx +24 -11
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/SelectFilter.tsx +9 -5
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/TextFilter.tsx +29 -12
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/hooks/useDebouncedCallback.ts +34 -0
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/hooks/useObjectSearchParams.ts +5 -86
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/utils/debounce.ts +4 -1
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/utils/filterUtils.ts +24 -1
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Application.tsx +10 -2
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Contact.tsx +5 -1
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Home.tsx +1 -1
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Maintenance.tsx +1 -1
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertyDetails.tsx +19 -2
- package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertySearch.tsx +1 -3
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
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",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
48
48
|
"@graphql-tools/utils": "^11.0.0",
|
|
49
49
|
"@playwright/test": "^1.49.0",
|
|
50
|
-
"@salesforce/vite-plugin-webapp-experimental": "^1.
|
|
50
|
+
"@salesforce/vite-plugin-webapp-experimental": "^1.117.1",
|
|
51
51
|
"@testing-library/jest-dom": "^6.6.3",
|
|
52
52
|
"@testing-library/react": "^16.1.0",
|
|
53
53
|
"@testing-library/user-event": "^14.5.2",
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
3
|
import { SearchBar } from "../SearchBar";
|
|
4
4
|
import { useFilterField } from "../FilterContext";
|
|
5
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
6
|
+
import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
|
|
5
7
|
|
|
6
8
|
interface SearchFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
7
9
|
field: string;
|
|
@@ -17,21 +19,32 @@ export function SearchFilter({
|
|
|
17
19
|
...props
|
|
18
20
|
}: SearchFilterProps) {
|
|
19
21
|
const { value, onChange } = useFilterField(field);
|
|
22
|
+
const [localValue, setLocalValue] = useState(value?.value ?? "");
|
|
23
|
+
|
|
24
|
+
const externalValue = value?.value ?? "";
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setLocalValue(externalValue);
|
|
27
|
+
}, [externalValue]);
|
|
28
|
+
|
|
29
|
+
const debouncedOnChange = useDebouncedCallback((v: string) => {
|
|
30
|
+
if (v) {
|
|
31
|
+
onChange({ field, label, type: "search", value: v });
|
|
32
|
+
} else {
|
|
33
|
+
onChange(undefined);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
20
37
|
return (
|
|
21
|
-
<
|
|
22
|
-
<Label htmlFor={`filter-${field}`}>{label}</Label>
|
|
38
|
+
<FilterFieldWrapper label={label} htmlFor={`filter-${field}`} className={className} {...props}>
|
|
23
39
|
<SearchBar
|
|
24
|
-
value={
|
|
40
|
+
value={localValue}
|
|
25
41
|
handleChange={(v) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} else {
|
|
29
|
-
onChange(undefined);
|
|
30
|
-
}
|
|
42
|
+
setLocalValue(v);
|
|
43
|
+
debouncedOnChange(v);
|
|
31
44
|
}}
|
|
32
45
|
placeholder={placeholder}
|
|
33
46
|
inputProps={{ id: `filter-${field}` }}
|
|
34
47
|
/>
|
|
35
|
-
</
|
|
48
|
+
</FilterFieldWrapper>
|
|
36
49
|
);
|
|
37
50
|
}
|
|
@@ -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__";
|
|
@@ -29,8 +29,13 @@ export function SelectFilter({
|
|
|
29
29
|
}: SelectFilterProps) {
|
|
30
30
|
const { value, onChange } = useFilterField(field);
|
|
31
31
|
return (
|
|
32
|
-
<
|
|
33
|
-
|
|
32
|
+
<FilterFieldWrapper
|
|
33
|
+
label={label}
|
|
34
|
+
htmlFor={`filter-${field}`}
|
|
35
|
+
helpText={helpText}
|
|
36
|
+
className={className}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
34
39
|
<SelectFilterControl
|
|
35
40
|
field={field}
|
|
36
41
|
label={label}
|
|
@@ -38,8 +43,7 @@ export function SelectFilter({
|
|
|
38
43
|
value={value}
|
|
39
44
|
onChange={onChange}
|
|
40
45
|
/>
|
|
41
|
-
|
|
42
|
-
</div>
|
|
46
|
+
</FilterFieldWrapper>
|
|
43
47
|
);
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
1
2
|
import { Input } from "../../../../components/ui/input";
|
|
2
|
-
import { Label } from "../../../../components/ui/label";
|
|
3
3
|
import { cn } from "../../../../lib/utils";
|
|
4
4
|
import { useFilterField } from "../FilterContext";
|
|
5
|
+
import { FilterFieldWrapper } from "./FilterFieldWrapper";
|
|
6
|
+
import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
|
|
5
7
|
import type { ActiveFilterValue } from "../../utils/filterUtils";
|
|
6
8
|
|
|
7
9
|
interface TextFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
|
|
@@ -21,8 +23,13 @@ export function TextFilter({
|
|
|
21
23
|
}: TextFilterProps) {
|
|
22
24
|
const { value, onChange } = useFilterField(field);
|
|
23
25
|
return (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
+
<FilterFieldWrapper
|
|
27
|
+
label={label}
|
|
28
|
+
htmlFor={`filter-${field}`}
|
|
29
|
+
helpText={helpText}
|
|
30
|
+
className={className}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
26
33
|
<TextFilterInput
|
|
27
34
|
field={field}
|
|
28
35
|
label={label}
|
|
@@ -30,8 +37,7 @@ export function TextFilter({
|
|
|
30
37
|
value={value}
|
|
31
38
|
onChange={onChange}
|
|
32
39
|
/>
|
|
33
|
-
|
|
34
|
-
</div>
|
|
40
|
+
</FilterFieldWrapper>
|
|
35
41
|
);
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -53,19 +59,30 @@ export function TextFilterInput({
|
|
|
53
59
|
className,
|
|
54
60
|
...props
|
|
55
61
|
}: TextFilterInputProps) {
|
|
62
|
+
const [localValue, setLocalValue] = useState(value?.value ?? "");
|
|
63
|
+
|
|
64
|
+
const externalValue = value?.value ?? "";
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setLocalValue(externalValue);
|
|
67
|
+
}, [externalValue]);
|
|
68
|
+
|
|
69
|
+
const debouncedOnChange = useDebouncedCallback((v: string) => {
|
|
70
|
+
if (v) {
|
|
71
|
+
onChange({ field, label, type: "text", value: v });
|
|
72
|
+
} else {
|
|
73
|
+
onChange(undefined);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
56
77
|
return (
|
|
57
78
|
<Input
|
|
58
79
|
id={`filter-${field}`}
|
|
59
80
|
type="text"
|
|
60
81
|
placeholder={props.placeholder ?? `Filter by ${label.toLowerCase()}...`}
|
|
61
|
-
value={
|
|
82
|
+
value={localValue}
|
|
62
83
|
onChange={(e) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
onChange({ field, label, type: "text", value: v });
|
|
66
|
-
} else {
|
|
67
|
-
onChange(undefined);
|
|
68
|
-
}
|
|
84
|
+
setLocalValue(e.target.value);
|
|
85
|
+
debouncedOnChange(e.target.value);
|
|
69
86
|
}}
|
|
70
87
|
className={cn(className)}
|
|
71
88
|
{...props}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { debounce, FILTER_DEBOUNCE_MS } from "../utils/debounce";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns a stable debounced wrapper around the provided callback.
|
|
6
|
+
*
|
|
7
|
+
* The wrapper always invokes the *latest* version of `fn` (via a ref),
|
|
8
|
+
* so the debounce timer is never reset when `fn` changes — only when
|
|
9
|
+
* the caller invokes the returned function again.
|
|
10
|
+
*
|
|
11
|
+
* @param fn - The callback to debounce.
|
|
12
|
+
* @param delay - Debounce delay in ms. Defaults to `FILTER_DEBOUNCE_MS`.
|
|
13
|
+
*/
|
|
14
|
+
export function useDebouncedCallback<T extends (...args: any[]) => void>(
|
|
15
|
+
fn: T,
|
|
16
|
+
delay: number = FILTER_DEBOUNCE_MS,
|
|
17
|
+
): (...args: Parameters<T>) => void {
|
|
18
|
+
const fnRef = useRef(fn);
|
|
19
|
+
const debouncedRef = useRef<((...args: any[]) => void) | null>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
fnRef.current = fn;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
debouncedRef.current = debounce((...args: any[]) => {
|
|
27
|
+
fnRef.current(...(args as Parameters<T>));
|
|
28
|
+
}, delay);
|
|
29
|
+
}, [delay]);
|
|
30
|
+
|
|
31
|
+
return useCallback((...args: Parameters<T>) => {
|
|
32
|
+
debouncedRef.current?.(...args);
|
|
33
|
+
}, []);
|
|
34
|
+
}
|
|
@@ -20,11 +20,6 @@ export interface UseObjectSearchParamsReturn<TFilter, TOrderBy> {
|
|
|
20
20
|
set: (field: string, value: ActiveFilterValue | undefined) => void;
|
|
21
21
|
remove: (field: string) => void;
|
|
22
22
|
};
|
|
23
|
-
filterState: {
|
|
24
|
-
apply: () => void;
|
|
25
|
-
hasPendingChanges: boolean;
|
|
26
|
-
hasValidationError: boolean;
|
|
27
|
-
};
|
|
28
23
|
sort: {
|
|
29
24
|
current: SortState | null;
|
|
30
25
|
set: (sort: SortState | null) => void;
|
|
@@ -41,40 +36,6 @@ export interface UseObjectSearchParamsReturn<TFilter, TOrderBy> {
|
|
|
41
36
|
resetAll: () => void;
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
export interface UseObjectSearchParamsOptions {
|
|
45
|
-
filterSyncMode?: "immediate" | "manual";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function areFiltersEqual(left: ActiveFilterValue[], right: ActiveFilterValue[]) {
|
|
49
|
-
if (left.length !== right.length) return false;
|
|
50
|
-
const normalize = (filters: ActiveFilterValue[]) =>
|
|
51
|
-
[...filters]
|
|
52
|
-
.sort((a, b) => a.field.localeCompare(b.field))
|
|
53
|
-
.map((filter) => ({
|
|
54
|
-
field: filter.field,
|
|
55
|
-
type: filter.type,
|
|
56
|
-
value: filter.value ?? "",
|
|
57
|
-
min: filter.min ?? "",
|
|
58
|
-
max: filter.max ?? "",
|
|
59
|
-
}));
|
|
60
|
-
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function hasFilterValidationError(filters: ActiveFilterValue[]) {
|
|
64
|
-
for (const filter of filters) {
|
|
65
|
-
if (filter.type !== "numeric") continue;
|
|
66
|
-
const min = filter.min?.trim();
|
|
67
|
-
const max = filter.max?.trim();
|
|
68
|
-
if (!min || !max) continue;
|
|
69
|
-
const minValue = Number(min);
|
|
70
|
-
const maxValue = Number(max);
|
|
71
|
-
if (!Number.isNaN(minValue) && !Number.isNaN(maxValue) && minValue >= maxValue) {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
39
|
/**
|
|
79
40
|
* Manages filter, sort, and cursor-based pagination state for an object search page.
|
|
80
41
|
*
|
|
@@ -98,11 +59,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
98
59
|
filterConfigs: FilterFieldConfig[],
|
|
99
60
|
_sortConfigs?: SortFieldConfig[],
|
|
100
61
|
paginationConfig?: PaginationConfig,
|
|
101
|
-
options?: UseObjectSearchParamsOptions,
|
|
102
62
|
) {
|
|
103
|
-
const filterSyncMode = options?.filterSyncMode ?? "immediate";
|
|
104
|
-
const isManualFilterSync = filterSyncMode === "manual";
|
|
105
|
-
|
|
106
63
|
const defaultPageSize = paginationConfig?.defaultPageSize ?? 10;
|
|
107
64
|
const validPageSizes = useMemo(
|
|
108
65
|
() => paginationConfig?.validPageSizes ?? [defaultPageSize],
|
|
@@ -119,7 +76,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
119
76
|
);
|
|
120
77
|
|
|
121
78
|
const [filters, setFilters] = useState<ActiveFilterValue[]>(initial.filters);
|
|
122
|
-
const [appliedFilters, setAppliedFilters] = useState<ActiveFilterValue[]>(initial.filters);
|
|
123
79
|
const [sort, setLocalSort] = useState<SortState | null>(initial.sort);
|
|
124
80
|
|
|
125
81
|
// Pagination — cursor-based with a stack to support "previous page" navigation.
|
|
@@ -170,15 +126,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
170
126
|
|
|
171
127
|
const setFilter = useCallback(
|
|
172
128
|
(field: string, value: ActiveFilterValue | undefined) => {
|
|
173
|
-
if (isManualFilterSync) {
|
|
174
|
-
setFilters((prev) => {
|
|
175
|
-
const next = prev.filter((f) => f.field !== field);
|
|
176
|
-
if (value) next.push(value);
|
|
177
|
-
return next;
|
|
178
|
-
});
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
129
|
const { sort: s, pageSize: ps } = stateRef.current;
|
|
183
130
|
setFilters((prev) => {
|
|
184
131
|
const next = prev.filter((f) => f.field !== field);
|
|
@@ -188,16 +135,11 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
188
135
|
});
|
|
189
136
|
resetPagination();
|
|
190
137
|
},
|
|
191
|
-
[
|
|
138
|
+
[resetPagination],
|
|
192
139
|
);
|
|
193
140
|
|
|
194
141
|
const removeFilter = useCallback(
|
|
195
142
|
(field: string) => {
|
|
196
|
-
if (isManualFilterSync) {
|
|
197
|
-
setFilters((prev) => prev.filter((f) => f.field !== field));
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
143
|
const { sort: s, pageSize: ps } = stateRef.current;
|
|
202
144
|
setFilters((prev) => {
|
|
203
145
|
const next = prev.filter((f) => f.field !== field);
|
|
@@ -206,17 +148,9 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
206
148
|
});
|
|
207
149
|
resetPagination();
|
|
208
150
|
},
|
|
209
|
-
[
|
|
151
|
+
[resetPagination],
|
|
210
152
|
);
|
|
211
153
|
|
|
212
|
-
const applyFilters = useCallback(() => {
|
|
213
|
-
if (!isManualFilterSync) return;
|
|
214
|
-
const { filters: nextFilters, sort: s, pageSize: ps } = stateRef.current;
|
|
215
|
-
setAppliedFilters(nextFilters);
|
|
216
|
-
resetPagination();
|
|
217
|
-
syncToUrl(nextFilters, s, ps);
|
|
218
|
-
}, [isManualFilterSync, resetPagination, syncToUrl]);
|
|
219
|
-
|
|
220
154
|
// -- Sort callback ----------------------------------------------------------
|
|
221
155
|
|
|
222
156
|
const setSort = useCallback(
|
|
@@ -233,7 +167,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
233
167
|
|
|
234
168
|
const resetAll = useCallback(() => {
|
|
235
169
|
setFilters([]);
|
|
236
|
-
setAppliedFilters([]);
|
|
237
170
|
setLocalSort(null);
|
|
238
171
|
resetPagination();
|
|
239
172
|
syncToUrl([], null, defaultPageSize, 0);
|
|
@@ -283,8 +216,8 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
283
216
|
// Translate local filter/sort state into API-ready `where` and `orderBy`.
|
|
284
217
|
|
|
285
218
|
const where = useMemo(
|
|
286
|
-
() => buildFilter<TFilter>(
|
|
287
|
-
[
|
|
219
|
+
() => buildFilter<TFilter>(filters, filterConfigs),
|
|
220
|
+
[filters, filterConfigs],
|
|
288
221
|
);
|
|
289
222
|
|
|
290
223
|
const orderBy = useMemo(() => buildOrderBy<TOrderBy>(sort), [sort]);
|
|
@@ -296,23 +229,10 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
296
229
|
// causing unnecessary re-renders.
|
|
297
230
|
|
|
298
231
|
const filtersGroup = useMemo(
|
|
299
|
-
() => ({
|
|
300
|
-
active: filters,
|
|
301
|
-
set: setFilter,
|
|
302
|
-
remove: removeFilter,
|
|
303
|
-
}),
|
|
232
|
+
() => ({ active: filters, set: setFilter, remove: removeFilter }),
|
|
304
233
|
[filters, setFilter, removeFilter],
|
|
305
234
|
);
|
|
306
235
|
|
|
307
|
-
const filterState = useMemo(
|
|
308
|
-
() => ({
|
|
309
|
-
apply: applyFilters,
|
|
310
|
-
hasPendingChanges: isManualFilterSync ? !areFiltersEqual(filters, appliedFilters) : false,
|
|
311
|
-
hasValidationError: hasFilterValidationError(filters),
|
|
312
|
-
}),
|
|
313
|
-
[applyFilters, isManualFilterSync, filters, appliedFilters],
|
|
314
|
-
);
|
|
315
|
-
|
|
316
236
|
const sortGroup = useMemo(() => ({ current: sort, set: setSort }), [sort, setSort]);
|
|
317
237
|
|
|
318
238
|
const query = useMemo(() => ({ where, orderBy }), [where, orderBy]);
|
|
@@ -324,7 +244,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
|
|
|
324
244
|
|
|
325
245
|
return {
|
|
326
246
|
filters: filtersGroup,
|
|
327
|
-
filterState,
|
|
328
247
|
sort: sortGroup,
|
|
329
248
|
query,
|
|
330
249
|
pagination,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Default debounce delay for keystroke-driven filter inputs (search, text, numeric). */
|
|
2
|
+
export const FILTER_DEBOUNCE_MS = 300;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Creates a debounced version of the provided function.
|
|
3
6
|
*
|
|
@@ -10,7 +13,7 @@
|
|
|
10
13
|
* @param ms - The debounce delay in milliseconds.
|
|
11
14
|
* @returns A new function with the same signature that delays execution.
|
|
12
15
|
*/
|
|
13
|
-
export function debounce<T extends (...args:
|
|
16
|
+
export function debounce<T extends (...args: any[]) => void>(
|
|
14
17
|
fn: T,
|
|
15
18
|
ms: number,
|
|
16
19
|
): (...args: Parameters<T>) => void {
|
|
@@ -25,6 +25,8 @@ export type FilterFieldType =
|
|
|
25
25
|
| "boolean"
|
|
26
26
|
| "date"
|
|
27
27
|
| "daterange"
|
|
28
|
+
| "datetime"
|
|
29
|
+
| "datetimerange"
|
|
28
30
|
| "multipicklist"
|
|
29
31
|
| "search";
|
|
30
32
|
|
|
@@ -337,13 +339,34 @@ function buildSingleFilter<TFilter>(
|
|
|
337
339
|
return { [field]: { in: values } } as TFilter;
|
|
338
340
|
}
|
|
339
341
|
case "date": {
|
|
342
|
+
if (!min && !max) return null;
|
|
343
|
+
const op = value ?? (min ? "gte" : "lte");
|
|
344
|
+
const dateStr = min ?? max;
|
|
345
|
+
return { [field]: { [op]: { value: dateStr } } } as TFilter;
|
|
346
|
+
}
|
|
347
|
+
case "daterange": {
|
|
348
|
+
if (!min && !max) return null;
|
|
349
|
+
const clauses: TFilter[] = [];
|
|
350
|
+
if (min) {
|
|
351
|
+
clauses.push({
|
|
352
|
+
[field]: { gte: { value: min } },
|
|
353
|
+
} as TFilter);
|
|
354
|
+
}
|
|
355
|
+
if (max) {
|
|
356
|
+
clauses.push({
|
|
357
|
+
[field]: { lte: { value: max } },
|
|
358
|
+
} as TFilter);
|
|
359
|
+
}
|
|
360
|
+
return clauses.length === 1 ? clauses[0] : ({ and: clauses } as TFilter);
|
|
361
|
+
}
|
|
362
|
+
case "datetime": {
|
|
340
363
|
if (!min && !max) return null;
|
|
341
364
|
const op = value ?? (min ? "gte" : "lte");
|
|
342
365
|
const dateStr = min ?? max;
|
|
343
366
|
const isoStr = op === "gte" || op === "gt" ? toStartOfDay(dateStr!) : toEndOfDay(dateStr!);
|
|
344
367
|
return { [field]: { [op]: { value: isoStr } } } as TFilter;
|
|
345
368
|
}
|
|
346
|
-
case "
|
|
369
|
+
case "datetimerange": {
|
|
347
370
|
if (!min && !max) return null;
|
|
348
371
|
const clauses: TFilter[] = [];
|
|
349
372
|
if (min) {
|
package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Application.tsx
CHANGED
|
@@ -170,7 +170,11 @@ export default function Application() {
|
|
|
170
170
|
"Select a property from search or listing detail to apply."
|
|
171
171
|
))}
|
|
172
172
|
</p>
|
|
173
|
-
{loadError &&
|
|
173
|
+
{loadError && (
|
|
174
|
+
<p className="mt-2 text-sm text-destructive">
|
|
175
|
+
Something went wrong. Please try again later.
|
|
176
|
+
</p>
|
|
177
|
+
)}
|
|
174
178
|
</div>
|
|
175
179
|
</Card>
|
|
176
180
|
|
|
@@ -208,7 +212,11 @@ export default function Application() {
|
|
|
208
212
|
onChange={(e) => setReferences(e.target.value)}
|
|
209
213
|
/>
|
|
210
214
|
</div>
|
|
211
|
-
{submitError &&
|
|
215
|
+
{submitError && (
|
|
216
|
+
<p className="mb-4 text-sm text-destructive">
|
|
217
|
+
Something went wrong. Please try again later.
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
212
220
|
<div className="flex gap-2">
|
|
213
221
|
<Button
|
|
214
222
|
type="submit"
|
|
@@ -147,7 +147,11 @@ function ContactForm({
|
|
|
147
147
|
className="min-h-[120px] w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
148
148
|
/>
|
|
149
149
|
</div>
|
|
150
|
-
{submitError &&
|
|
150
|
+
{submitError && (
|
|
151
|
+
<p className="text-sm text-destructive">
|
|
152
|
+
Something went wrong. Please try again later.
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
151
155
|
<Button type="submit" disabled={submitting} className="bg-teal-600 hover:bg-teal-700">
|
|
152
156
|
{submitting ? "Sending…" : "Send message"}
|
|
153
157
|
</Button>
|
|
@@ -147,7 +147,7 @@ export default function Home() {
|
|
|
147
147
|
const handleFindHome = (e: React.FormEvent) => {
|
|
148
148
|
e.preventDefault();
|
|
149
149
|
const q = searchInputRef.current?.value?.trim() ?? "";
|
|
150
|
-
navigate(q ? `/properties?
|
|
150
|
+
navigate(q ? `/properties?q=${encodeURIComponent(q)}` : "/properties");
|
|
151
151
|
};
|
|
152
152
|
|
|
153
153
|
const handleFooterSubmit = async (e: React.FormEvent) => {
|
package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertyDetails.tsx
CHANGED
|
@@ -138,7 +138,7 @@ export default function PropertyDetails() {
|
|
|
138
138
|
return <PropertyDetailsSkeleton />;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
if (error
|
|
141
|
+
if (error) {
|
|
142
142
|
return (
|
|
143
143
|
<div className="mx-auto max-w-[900px]">
|
|
144
144
|
<div className="mb-4">
|
|
@@ -148,7 +148,24 @@ export default function PropertyDetails() {
|
|
|
148
148
|
</div>
|
|
149
149
|
<Card className="rounded-2xl border border-border shadow-sm">
|
|
150
150
|
<CardContent className="pt-6">
|
|
151
|
-
<p className="text-destructive">
|
|
151
|
+
<p className="text-destructive">Something went wrong. Please try again later.</p>
|
|
152
|
+
</CardContent>
|
|
153
|
+
</Card>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!property && id) {
|
|
159
|
+
return (
|
|
160
|
+
<div className="mx-auto max-w-[900px]">
|
|
161
|
+
<div className="mb-4">
|
|
162
|
+
<Link to="/properties" className="text-sm text-primary no-underline hover:underline">
|
|
163
|
+
← Back to listings
|
|
164
|
+
</Link>
|
|
165
|
+
</div>
|
|
166
|
+
<Card className="rounded-2xl border border-border shadow-sm">
|
|
167
|
+
<CardContent className="pt-6">
|
|
168
|
+
<p className="text-destructive">Listing not found.</p>
|
|
152
169
|
</CardContent>
|
|
153
170
|
</Card>
|
|
154
171
|
</div>
|
package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertySearch.tsx
CHANGED
|
@@ -401,9 +401,7 @@ export default function PropertySearch() {
|
|
|
401
401
|
</div>
|
|
402
402
|
<div className="flex-1 overflow-y-auto p-4">
|
|
403
403
|
{apiUnavailable ? (
|
|
404
|
-
<PropertySearchPlaceholder
|
|
405
|
-
message={resultsError ?? "Search is temporarily unavailable."}
|
|
406
|
-
/>
|
|
404
|
+
<PropertySearchPlaceholder message="Search is temporarily unavailable." />
|
|
407
405
|
) : resultsLoading ? (
|
|
408
406
|
<div className="space-y-4">
|
|
409
407
|
{[1, 2, 3].map((i) => (
|
package/dist/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/webapp-template-base-sfdx-project-experimental",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.117.1",
|
|
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": "1.
|
|
9
|
+
"version": "1.117.1",
|
|
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