@salesforce/webapp-template-app-react-template-b2e-experimental 1.117.0 → 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.
Files changed (21) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/webapplications/reactinternalapp/package.json +3 -3
  3. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +15 -6
  4. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/FilterContext.tsx +1 -32
  5. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/BooleanFilter.tsx +9 -5
  6. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/DateFilter.tsx +15 -8
  7. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/DateRangeFilter.tsx +8 -7
  8. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +33 -0
  9. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +4 -5
  10. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +113 -82
  11. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/SearchFilter.tsx +24 -11
  12. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/SelectFilter.tsx +9 -5
  13. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/components/filters/TextFilter.tsx +29 -12
  14. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/hooks/useDebouncedCallback.ts +34 -0
  15. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/hooks/useObjectSearchParams.ts +5 -86
  16. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/utils/debounce.ts +4 -1
  17. package/dist/force-app/main/default/webapplications/reactinternalapp/src/features/object-search/utils/filterUtils.ts +24 -1
  18. package/dist/force-app/main/default/webapplications/reactinternalapp/src/pages/AccountSearch.tsx +9 -5
  19. package/dist/package-lock.json +2 -2
  20. package/dist/package.json +1 -1
  21. package/package.json +1 -1
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
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
+
6
14
  # [1.117.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.13...v1.117.0) (2026-03-27)
7
15
 
8
16
  **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.117.0",
19
- "@salesforce/webapp-experimental": "^1.117.0",
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",
@@ -42,7 +42,7 @@
42
42
  "@graphql-eslint/eslint-plugin": "^4.1.0",
43
43
  "@graphql-tools/utils": "^11.0.0",
44
44
  "@playwright/test": "^1.49.0",
45
- "@salesforce/vite-plugin-webapp-experimental": "^1.117.0",
45
+ "@salesforce/vite-plugin-webapp-experimental": "^1.117.1",
46
46
  "@testing-library/jest-dom": "^6.6.3",
47
47
  "@testing-library/react": "^16.1.0",
48
48
  "@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: "date" },
66
- { field: "LastModifiedDate", label: "Last Modified Date", type: "daterange" },
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-4 pt-0">
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 field="AnnualRevenue" label="Annual Revenue" />
169
- <DateFilter field="CreatedDate" label="Created Date" />
170
- <DateRangeFilter field="LastModifiedDate" label="Last Modified Date" />
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, onApply, hasPendingChanges, hasValidationError } = useFilterContext();
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
- <div className={cn("space-y-1.5", className)} {...props}>
25
- <Label htmlFor={`filter-${field}`}>{label}</Label>
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
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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
- import { cn } from "../../../../lib/utils";
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({ field, label, helpText, className, ...props }: DateFilterProps) {
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: "date",
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
- <div className={cn("space-y-1.5", className)} {...props}>
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
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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
- import { cn } from "../../../../lib/utils";
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: "daterange",
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
- <div className={cn("space-y-1.5", className)} {...props}>
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
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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
- <div className={cn("space-y-1.5", className)} {...props}>
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
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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
- import { Label } from "../../../../components/ui/label";
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
- minInputProps?: React.ComponentProps<typeof Input>;
13
- maxInputProps?: React.ComponentProps<typeof Input>;
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
- <div className={cn("space-y-1.5", className)} {...props}>
28
- <Label>{label}</Label>
29
- <NumericRangeFilterInputs
30
- field={field}
31
- label={label}
32
- value={value}
33
- onChange={onChange}
34
- minInputProps={minInputProps}
35
- maxInputProps={maxInputProps}
36
- />
37
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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 validateNumericRangeFilter = (filter: ActiveFilterValue) => {
62
- if (filter.type !== "numeric") return null;
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
- if (!min || !max) return null;
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
- const minValue = Number(min);
71
- const maxValue = Number(max);
72
- if (!Number.isNaN(minValue) && !Number.isNaN(maxValue) && minValue >= maxValue) {
73
- return `${filterLabel}: minimum value must be less than maximum value.`;
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 handleChange = (bound: "min" | "max", v: string) => {
80
- const next = {
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
- } else {
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 handleBlur = (bound: "min" | "max", currentValue: string) => {
97
- const next = {
98
- field,
99
- label,
100
- type: "numeric" as const,
101
- min: bound === "min" ? currentValue : (value?.min ?? ""),
102
- max: bound === "max" ? currentValue : (value?.max ?? ""),
103
- };
104
- const validationError = validateNumericRangeFilter(next);
105
- if (validationError) {
106
- toast.error("Invalid range filter", { description: validationError });
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
- <div className={cn("flex gap-2", className)} {...props}>
112
- <Input
113
- type="number"
114
- placeholder="Min"
115
- value={value?.min ?? ""}
116
- onChange={(e) => handleChange("min", e.target.value)}
117
- onBlur={(e) => handleBlur("min", e.target.value)}
118
- aria-label={`${label} minimum`}
119
- {...minInputProps}
120
- />
121
- <Input
122
- type="number"
123
- placeholder="Max"
124
- value={value?.max ?? ""}
125
- onChange={(e) => handleChange("max", e.target.value)}
126
- onBlur={(e) => handleBlur("max", e.target.value)}
127
- aria-label={`${label} maximum`}
128
- {...maxInputProps}
129
- />
130
- </div>
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 { Label } from "../../../../components/ui/label";
2
- import { cn } from "../../../../lib/utils";
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
- <div className={cn("space-y-1.5", className)} {...props}>
22
- <Label htmlFor={`filter-${field}`}>{label}</Label>
38
+ <FilterFieldWrapper label={label} htmlFor={`filter-${field}`} className={className} {...props}>
23
39
  <SearchBar
24
- value={value?.value ?? ""}
40
+ value={localValue}
25
41
  handleChange={(v) => {
26
- if (v) {
27
- onChange({ field, label, type: "search", value: v });
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
- </div>
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
- <div className={cn("space-y-1.5", className)} {...props}>
33
- <Label htmlFor={`filter-${field}`}>{label}</Label>
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
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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
- <div className={cn("space-y-1.5", className)} {...props}>
25
- <Label htmlFor={`filter-${field}`}>{label}</Label>
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
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
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={value?.value ?? ""}
82
+ value={localValue}
62
83
  onChange={(e) => {
63
- const v = e.target.value;
64
- if (v) {
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
- [isManualFilterSync, resetPagination],
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
- [isManualFilterSync, resetPagination],
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>(isManualFilterSync ? appliedFilters : filters, filterConfigs),
287
- [appliedFilters, filters, filterConfigs, isManualFilterSync],
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: never[]) => void>(
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 "daterange": {
369
+ case "datetimerange": {
347
370
  if (!min && !max) return null;
348
371
  const clauses: TFilter[] = [];
349
372
  if (min) {
@@ -60,8 +60,8 @@ const FILTER_CONFIGS: FilterFieldConfig[] = [
60
60
  { field: "Industry", label: "Industry", type: "picklist" },
61
61
  { field: "Type", label: "Type", type: "multipicklist" },
62
62
  { field: "AnnualRevenue", label: "Annual Revenue", type: "numeric" },
63
- { field: "CreatedDate", label: "Created Date", type: "date" },
64
- { field: "LastModifiedDate", label: "Last Modified Date", type: "daterange" },
63
+ { field: "CreatedDate", label: "Created Date", type: "datetime" },
64
+ { field: "LastModifiedDate", label: "Last Modified Date", type: "datetimerange" },
65
65
  ];
66
66
 
67
67
  const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
@@ -150,7 +150,7 @@ export default function AccountSearch() {
150
150
  </div>
151
151
  </CardHeader>
152
152
  <CollapsibleContent>
153
- <CardContent className="space-y-4 pt-0">
153
+ <CardContent className="space-y-1 pt-0">
154
154
  <SearchFilter
155
155
  field="search"
156
156
  label="Search"
@@ -164,8 +164,12 @@ export default function AccountSearch() {
164
164
  />
165
165
  <MultiSelectFilter field="Type" label="Type" options={typeOptions ?? []} />
166
166
  <NumericRangeFilter field="AnnualRevenue" label="Annual Revenue" />
167
- <DateFilter field="CreatedDate" label="Created Date" />
168
- <DateRangeFilter field="LastModifiedDate" label="Last Modified Date" />
167
+ <DateFilter field="CreatedDate" label="Created Date" filterType="datetime" />
168
+ <DateRangeFilter
169
+ field="LastModifiedDate"
170
+ label="Last Modified Date"
171
+ filterType="datetimerange"
172
+ />
169
173
  </CardContent>
170
174
  </CollapsibleContent>
171
175
  </Collapsible>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "1.117.0",
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.117.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "1.117.0",
3
+ "version": "1.117.1",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-app-react-template-b2e-experimental",
3
- "version": "1.117.0",
3
+ "version": "1.117.1",
4
4
  "description": "Salesforce React internal app template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",