@salesforce/webapp-template-app-react-sample-b2e-experimental 1.116.12 → 1.116.13

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 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.116.13](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.12...v1.116.13) (2026-03-27)
7
+
8
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
9
+
10
+
11
+
12
+
13
+
6
14
  ## [1.116.12](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.11...v1.116.12) (2026-03-27)
7
15
 
8
16
 
@@ -15,8 +15,8 @@
15
15
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@salesforce/sdk-data": "^1.116.12",
19
- "@salesforce/webapp-experimental": "^1.116.12",
18
+ "@salesforce/sdk-data": "^1.116.13",
19
+ "@salesforce/webapp-experimental": "^1.116.13",
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.116.12",
46
+ "@salesforce/vite-plugin-webapp-experimental": "^1.116.13",
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",
@@ -7,6 +7,9 @@ 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;
10
13
  }
11
14
 
12
15
  const FilterContext = createContext<FilterContextValue | null>(null);
@@ -16,6 +19,9 @@ interface FilterProviderProps {
16
19
  onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
17
20
  onFilterRemove: (field: string) => void;
18
21
  onReset: () => void;
22
+ onApply?: () => void;
23
+ hasPendingChanges?: boolean;
24
+ hasValidationError?: boolean;
19
25
  children: ReactNode;
20
26
  }
21
27
 
@@ -24,10 +30,23 @@ export function FilterProvider({
24
30
  onFilterChange,
25
31
  onFilterRemove,
26
32
  onReset,
33
+ onApply,
34
+ hasPendingChanges = false,
35
+ hasValidationError = false,
27
36
  children,
28
37
  }: FilterProviderProps) {
29
38
  return (
30
- <FilterContext.Provider value={{ filters, onFilterChange, onFilterRemove, onReset }}>
39
+ <FilterContext.Provider
40
+ value={{
41
+ filters,
42
+ onFilterChange,
43
+ onFilterRemove,
44
+ onReset,
45
+ onApply: onApply ?? (() => {}),
46
+ hasPendingChanges,
47
+ hasValidationError,
48
+ }}
49
+ >
31
50
  {children}
32
51
  </FilterContext.Provider>
33
52
  );
@@ -56,8 +75,14 @@ export function useFilterField(field: string) {
56
75
  }
57
76
 
58
77
  export function useFilterPanel() {
59
- const { filters, onReset } = useFilterContext();
60
- return { hasActiveFilters: filters.length > 0, resetAll: onReset };
78
+ const { filters, onReset, onApply, hasPendingChanges, hasValidationError } = useFilterContext();
79
+ return {
80
+ hasActiveFilters: filters.length > 0,
81
+ hasPendingChanges,
82
+ hasValidationError,
83
+ resetAll: onReset,
84
+ apply: onApply,
85
+ };
61
86
  }
62
87
 
63
88
  type FilterResetButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick">;
@@ -71,3 +96,19 @@ export function FilterResetButton({ children, ...props }: FilterResetButtonProps
71
96
  </Button>
72
97
  );
73
98
  }
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { Input } from "../../../../components/ui/input";
2
2
  import { Label } from "../../../../components/ui/label";
3
+ import { toast } from "sonner";
3
4
  import { cn } from "../../../../lib/utils";
4
5
  import { useFilterField } from "../FilterContext";
5
6
  import type { ActiveFilterValue } from "../../utils/filterUtils";
@@ -8,6 +9,8 @@ interface NumericRangeFilterProps extends Omit<React.ComponentProps<"div">, "onC
8
9
  field: string;
9
10
  label: string;
10
11
  helpText?: string;
12
+ minInputProps?: React.ComponentProps<typeof Input>;
13
+ maxInputProps?: React.ComponentProps<typeof Input>;
11
14
  }
12
15
 
13
16
  export function NumericRangeFilter({
@@ -15,13 +18,22 @@ export function NumericRangeFilter({
15
18
  label,
16
19
  helpText,
17
20
  className,
21
+ minInputProps,
22
+ maxInputProps,
18
23
  ...props
19
24
  }: NumericRangeFilterProps) {
20
25
  const { value, onChange } = useFilterField(field);
21
26
  return (
22
27
  <div className={cn("space-y-1.5", className)} {...props}>
23
28
  <Label>{label}</Label>
24
- <NumericRangeFilterInputs field={field} label={label} value={value} onChange={onChange} />
29
+ <NumericRangeFilterInputs
30
+ field={field}
31
+ label={label}
32
+ value={value}
33
+ onChange={onChange}
34
+ minInputProps={minInputProps}
35
+ maxInputProps={maxInputProps}
36
+ />
25
37
  {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
26
38
  </div>
27
39
  );
@@ -46,6 +58,24 @@ export function NumericRangeFilterInputs({
46
58
  maxInputProps,
47
59
  ...props
48
60
  }: 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;
67
+
68
+ if (!min || !max) return null;
69
+
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
+ };
78
+
49
79
  const handleChange = (bound: "min" | "max", v: string) => {
50
80
  const next = {
51
81
  field,
@@ -55,6 +85,7 @@ export function NumericRangeFilterInputs({
55
85
  max: value?.max ?? "",
56
86
  [bound]: v,
57
87
  };
88
+
58
89
  if (!next.min && !next.max) {
59
90
  onChange(undefined);
60
91
  } else {
@@ -62,6 +93,20 @@ export function NumericRangeFilterInputs({
62
93
  }
63
94
  };
64
95
 
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
+ };
109
+
65
110
  return (
66
111
  <div className={cn("flex gap-2", className)} {...props}>
67
112
  <Input
@@ -69,6 +114,7 @@ export function NumericRangeFilterInputs({
69
114
  placeholder="Min"
70
115
  value={value?.min ?? ""}
71
116
  onChange={(e) => handleChange("min", e.target.value)}
117
+ onBlur={(e) => handleBlur("min", e.target.value)}
72
118
  aria-label={`${label} minimum`}
73
119
  {...minInputProps}
74
120
  />
@@ -77,6 +123,7 @@ export function NumericRangeFilterInputs({
77
123
  placeholder="Max"
78
124
  value={value?.max ?? ""}
79
125
  onChange={(e) => handleChange("max", e.target.value)}
126
+ onBlur={(e) => handleBlur("max", e.target.value)}
80
127
  aria-label={`${label} maximum`}
81
128
  {...maxInputProps}
82
129
  />
@@ -20,6 +20,11 @@ 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
+ };
23
28
  sort: {
24
29
  current: SortState | null;
25
30
  set: (sort: SortState | null) => void;
@@ -36,6 +41,40 @@ export interface UseObjectSearchParamsReturn<TFilter, TOrderBy> {
36
41
  resetAll: () => void;
37
42
  }
38
43
 
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
+
39
78
  /**
40
79
  * Manages filter, sort, and cursor-based pagination state for an object search page.
41
80
  *
@@ -59,7 +98,11 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
59
98
  filterConfigs: FilterFieldConfig[],
60
99
  _sortConfigs?: SortFieldConfig[],
61
100
  paginationConfig?: PaginationConfig,
101
+ options?: UseObjectSearchParamsOptions,
62
102
  ) {
103
+ const filterSyncMode = options?.filterSyncMode ?? "immediate";
104
+ const isManualFilterSync = filterSyncMode === "manual";
105
+
63
106
  const defaultPageSize = paginationConfig?.defaultPageSize ?? 10;
64
107
  const validPageSizes = useMemo(
65
108
  () => paginationConfig?.validPageSizes ?? [defaultPageSize],
@@ -76,6 +119,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
76
119
  );
77
120
 
78
121
  const [filters, setFilters] = useState<ActiveFilterValue[]>(initial.filters);
122
+ const [appliedFilters, setAppliedFilters] = useState<ActiveFilterValue[]>(initial.filters);
79
123
  const [sort, setLocalSort] = useState<SortState | null>(initial.sort);
80
124
 
81
125
  // Pagination — cursor-based with a stack to support "previous page" navigation.
@@ -126,6 +170,15 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
126
170
 
127
171
  const setFilter = useCallback(
128
172
  (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
+
129
182
  const { sort: s, pageSize: ps } = stateRef.current;
130
183
  setFilters((prev) => {
131
184
  const next = prev.filter((f) => f.field !== field);
@@ -135,11 +188,16 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
135
188
  });
136
189
  resetPagination();
137
190
  },
138
- [resetPagination],
191
+ [isManualFilterSync, resetPagination],
139
192
  );
140
193
 
141
194
  const removeFilter = useCallback(
142
195
  (field: string) => {
196
+ if (isManualFilterSync) {
197
+ setFilters((prev) => prev.filter((f) => f.field !== field));
198
+ return;
199
+ }
200
+
143
201
  const { sort: s, pageSize: ps } = stateRef.current;
144
202
  setFilters((prev) => {
145
203
  const next = prev.filter((f) => f.field !== field);
@@ -148,9 +206,17 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
148
206
  });
149
207
  resetPagination();
150
208
  },
151
- [resetPagination],
209
+ [isManualFilterSync, resetPagination],
152
210
  );
153
211
 
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
+
154
220
  // -- Sort callback ----------------------------------------------------------
155
221
 
156
222
  const setSort = useCallback(
@@ -167,6 +233,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
167
233
 
168
234
  const resetAll = useCallback(() => {
169
235
  setFilters([]);
236
+ setAppliedFilters([]);
170
237
  setLocalSort(null);
171
238
  resetPagination();
172
239
  syncToUrl([], null, defaultPageSize, 0);
@@ -216,8 +283,8 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
216
283
  // Translate local filter/sort state into API-ready `where` and `orderBy`.
217
284
 
218
285
  const where = useMemo(
219
- () => buildFilter<TFilter>(filters, filterConfigs),
220
- [filters, filterConfigs],
286
+ () => buildFilter<TFilter>(isManualFilterSync ? appliedFilters : filters, filterConfigs),
287
+ [appliedFilters, filters, filterConfigs, isManualFilterSync],
221
288
  );
222
289
 
223
290
  const orderBy = useMemo(() => buildOrderBy<TOrderBy>(sort), [sort]);
@@ -229,10 +296,23 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
229
296
  // causing unnecessary re-renders.
230
297
 
231
298
  const filtersGroup = useMemo(
232
- () => ({ active: filters, set: setFilter, remove: removeFilter }),
299
+ () => ({
300
+ active: filters,
301
+ set: setFilter,
302
+ remove: removeFilter,
303
+ }),
233
304
  [filters, setFilter, removeFilter],
234
305
  );
235
306
 
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
+
236
316
  const sortGroup = useMemo(() => ({ current: sort, set: setSort }), [sort, setSort]);
237
317
 
238
318
  const query = useMemo(() => ({ where, orderBy }), [where, orderBy]);
@@ -244,6 +324,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
244
324
 
245
325
  return {
246
326
  filters: filtersGroup,
327
+ filterState,
247
328
  sort: sortGroup,
248
329
  query,
249
330
  pagination,
@@ -0,0 +1,11 @@
1
+ import type { ComponentProps } from "react";
2
+
3
+ export const nonNegativeNumberInputProps: ComponentProps<"input"> = {
4
+ min: 0,
5
+ onKeyDown: (event) => {
6
+ if (event.key === "-" || event.key === "Minus") event.preventDefault();
7
+ },
8
+ onPaste: (event) => {
9
+ if (event.clipboardData.getData("text").includes("-")) event.preventDefault();
10
+ },
11
+ };
@@ -16,12 +16,13 @@ import type { ApplicationSearchNode } from "../api/applications/applicationSearc
16
16
  import { PageHeader } from "../components/layout/PageHeader";
17
17
  import { PageContainer } from "../components/layout/PageContainer";
18
18
  import {
19
+ FilterApplyButton,
19
20
  FilterProvider,
20
21
  FilterResetButton,
21
22
  } from "../features/object-search/components/FilterContext";
22
23
  import { FilterRow } from "../components/layout/FilterRow";
23
24
  import { SearchFilter } from "../features/object-search/components/filters/SearchFilter";
24
- import { SelectFilter } from "../features/object-search/components/filters/SelectFilter";
25
+ import { MultiSelectFilter } from "../features/object-search/components/filters/MultiSelectFilter";
25
26
  import { DateFilter } from "../features/object-search/components/filters/DateFilter";
26
27
  import { ObjectSearchErrorState } from "../components/shared/ObjectSearchErrorState";
27
28
  import PaginationControls from "../features/object-search/components/PaginationControls";
@@ -75,14 +76,13 @@ export default function ApplicationSearch() {
75
76
  ttl: 30_000,
76
77
  });
77
78
 
78
- const { filters, query, pagination, resetAll } = useObjectSearchParams<
79
+ const { filters, filterState, query, pagination, resetAll } = useObjectSearchParams<
79
80
  Application__C_Filter,
80
81
  Application__C_OrderBy
81
- >(FILTER_CONFIGS, SORT_CONFIGS, PAGINATION_CONFIG);
82
+ >(FILTER_CONFIGS, SORT_CONFIGS, PAGINATION_CONFIG, { filterSyncMode: "manual" });
82
83
  const effectiveOrderBy: Application__C_OrderBy = query.orderBy ?? {
83
84
  CreatedDate: { order: ResultOrder.Desc },
84
85
  };
85
-
86
86
  const searchKey = `applications:${JSON.stringify({ where: query.where, orderBy: effectiveOrderBy, first: pagination.pageSize, after: pagination.afterCursor })}`;
87
87
  const { data, loading, error } = useCachedAsyncData(
88
88
  () =>
@@ -138,6 +138,7 @@ export default function ApplicationSearch() {
138
138
  <PageHeader title="Applications" description="Manage and review rental applications" />
139
139
  <ApplicationSearchFilters
140
140
  filters={filters}
141
+ filterState={filterState}
141
142
  statusOptions={statusOptions ?? []}
142
143
  resetAll={resetAll}
143
144
  />
@@ -192,10 +193,15 @@ export default function ApplicationSearch() {
192
193
 
193
194
  function ApplicationSearchFilters({
194
195
  filters,
196
+ filterState,
195
197
  statusOptions,
196
198
  resetAll,
197
199
  }: {
198
200
  filters: UseObjectSearchParamsReturn<Application__C_Filter, Application__C_OrderBy>["filters"];
201
+ filterState: UseObjectSearchParamsReturn<
202
+ Application__C_Filter,
203
+ Application__C_OrderBy
204
+ >["filterState"];
199
205
  statusOptions: Array<{ value: string; label: string }>;
200
206
  resetAll: () => void;
201
207
  }) {
@@ -205,6 +211,9 @@ function ApplicationSearchFilters({
205
211
  onFilterChange={filters.set}
206
212
  onFilterRemove={filters.remove}
207
213
  onReset={resetAll}
214
+ onApply={filterState.apply}
215
+ hasPendingChanges={filterState.hasPendingChanges}
216
+ hasValidationError={filterState.hasValidationError}
208
217
  >
209
218
  <FilterRow ariaLabel="Applications filters">
210
219
  <SearchFilter
@@ -213,7 +222,7 @@ function ApplicationSearchFilters({
213
222
  placeholder="Search by name..."
214
223
  className="w-full sm:w-50"
215
224
  />
216
- <SelectFilter
225
+ <MultiSelectFilter
217
226
  field="Status__c"
218
227
  label="Status"
219
228
  options={statusOptions ?? []}
@@ -221,6 +230,7 @@ function ApplicationSearchFilters({
221
230
  />
222
231
  <DateFilter field="Start_Date__c" label="Start Date" className="w-full sm:w-56" />
223
232
  <DateFilter field="CreatedDate" label="Created Date" className="w-full sm:w-56" />
233
+ <FilterApplyButton className="h-8 px-3 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 disabled:bg-purple-300 disabled:cursor-not-allowed transition-colors" />
224
234
  <FilterResetButton />
225
235
  </FilterRow>
226
236
  </FilterProvider>
@@ -18,12 +18,13 @@ import type { SortFieldConfig } from "../features/object-search/utils/sortUtils"
18
18
  import { PageHeader } from "../components/layout/PageHeader";
19
19
  import { PageContainer } from "../components/layout/PageContainer";
20
20
  import {
21
+ FilterApplyButton,
21
22
  FilterProvider,
22
23
  FilterResetButton,
23
24
  } from "../features/object-search/components/FilterContext";
24
25
  import { FilterRow } from "../components/layout/FilterRow";
25
26
  import { SearchFilter } from "../features/object-search/components/filters/SearchFilter";
26
- import { SelectFilter } from "../features/object-search/components/filters/SelectFilter";
27
+ import { MultiSelectFilter } from "../features/object-search/components/filters/MultiSelectFilter";
27
28
  import { DateFilter } from "../features/object-search/components/filters/DateFilter";
28
29
  import { ObjectSearchErrorState } from "../components/shared/ObjectSearchErrorState";
29
30
  import PaginationControls from "../features/object-search/components/PaginationControls";
@@ -101,14 +102,13 @@ export default function MaintenanceRequestSearch() {
101
102
  { key: "distinctMaintenanceRequestPriority", ttl: 30_000 },
102
103
  );
103
104
 
104
- const { filters, query, pagination, resetAll } = useObjectSearchParams<
105
+ const { filters, filterState, query, pagination, resetAll } = useObjectSearchParams<
105
106
  Maintenance_Request__C_Filter,
106
107
  Maintenance_Request__C_OrderBy
107
- >(FILTER_CONFIGS, SORT_CONFIGS, PAGINATION_CONFIG);
108
+ >(FILTER_CONFIGS, SORT_CONFIGS, PAGINATION_CONFIG, { filterSyncMode: "manual" });
108
109
  const effectiveOrderBy: Maintenance_Request__C_OrderBy = query.orderBy ?? {
109
110
  CreatedDate: { order: ResultOrder.Desc },
110
111
  };
111
-
112
112
  const searchKey = `maintenance-requests:${JSON.stringify({ where: query.where, orderBy: effectiveOrderBy, first: pagination.pageSize, after: pagination.afterCursor })}`;
113
113
  const { data, loading, error } = useCachedAsyncData(
114
114
  () =>
@@ -167,6 +167,7 @@ export default function MaintenanceRequestSearch() {
167
167
  />
168
168
  <MaintenanceRequestSearchFilters
169
169
  filters={filters}
170
+ filterState={filterState}
170
171
  statusOptions={statusOptions ?? []}
171
172
  typeOptions={typeOptions ?? []}
172
173
  priorityOptions={priorityOptions ?? []}
@@ -224,6 +225,7 @@ export default function MaintenanceRequestSearch() {
224
225
 
225
226
  function MaintenanceRequestSearchFilters({
226
227
  filters,
228
+ filterState,
227
229
  statusOptions,
228
230
  typeOptions,
229
231
  priorityOptions,
@@ -233,6 +235,10 @@ function MaintenanceRequestSearchFilters({
233
235
  Maintenance_Request__C_Filter,
234
236
  Maintenance_Request__C_OrderBy
235
237
  >["filters"];
238
+ filterState: UseObjectSearchParamsReturn<
239
+ Maintenance_Request__C_Filter,
240
+ Maintenance_Request__C_OrderBy
241
+ >["filterState"];
236
242
  statusOptions: Array<{ value: string; label: string }>;
237
243
  typeOptions: Array<{ value: string; label: string }>;
238
244
  priorityOptions: Array<{ value: string; label: string }>;
@@ -244,6 +250,9 @@ function MaintenanceRequestSearchFilters({
244
250
  onFilterChange={filters.set}
245
251
  onFilterRemove={filters.remove}
246
252
  onReset={resetAll}
253
+ onApply={filterState.apply}
254
+ hasPendingChanges={filterState.hasPendingChanges}
255
+ hasValidationError={filterState.hasValidationError}
247
256
  >
248
257
  <FilterRow ariaLabel="Maintenance Requests filters">
249
258
  <SearchFilter
@@ -252,25 +261,26 @@ function MaintenanceRequestSearchFilters({
252
261
  placeholder="Search by name..."
253
262
  className="w-full sm:w-50"
254
263
  />
255
- <SelectFilter
264
+ <MultiSelectFilter
256
265
  field="Status__c"
257
266
  label="Status"
258
267
  options={statusOptions}
259
268
  className="w-full sm:w-36"
260
269
  />
261
- <SelectFilter
270
+ <MultiSelectFilter
262
271
  field="Type__c"
263
272
  label="Type"
264
273
  options={typeOptions}
265
274
  className="w-full sm:w-36"
266
275
  />
267
- <SelectFilter
276
+ <MultiSelectFilter
268
277
  field="Priority__c"
269
278
  label="Priority"
270
279
  options={priorityOptions}
271
280
  className="w-full sm:w-36"
272
281
  />
273
282
  <DateFilter field="Scheduled__c" label="Scheduled" className="w-full sm:w-56" />
283
+ <FilterApplyButton className="h-8 px-3 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 disabled:bg-purple-300 disabled:cursor-not-allowed transition-colors" />
274
284
  <FilterResetButton />
275
285
  </FilterRow>
276
286
  </FilterProvider>
@@ -15,12 +15,13 @@ import type { SortFieldConfig } from "../features/object-search/utils/sortUtils"
15
15
  import { PageHeader } from "../components/layout/PageHeader";
16
16
  import { PageContainer } from "../components/layout/PageContainer";
17
17
  import {
18
+ FilterApplyButton,
18
19
  FilterProvider,
19
20
  FilterResetButton,
20
21
  } from "../features/object-search/components/FilterContext";
21
22
  import { FilterRow } from "../components/layout/FilterRow";
22
23
  import { SearchFilter } from "../features/object-search/components/filters/SearchFilter";
23
- import { SelectFilter } from "../features/object-search/components/filters/SelectFilter";
24
+ import { MultiSelectFilter } from "../features/object-search/components/filters/MultiSelectFilter";
24
25
  import { TextFilter } from "../features/object-search/components/filters/TextFilter";
25
26
  import { NumericRangeFilter } from "../features/object-search/components/filters/NumericRangeFilter";
26
27
  import { DateFilter } from "../features/object-search/components/filters/DateFilter";
@@ -41,6 +42,7 @@ import type {
41
42
  Maintenance_Worker__C_OrderBy,
42
43
  } from "../api/graphql-operations-types";
43
44
  import { PAGINATION_CONFIG } from "../lib/constants";
45
+ import { nonNegativeNumberInputProps } from "../lib/filterUtils";
44
46
 
45
47
  const FILTER_CONFIGS: FilterFieldConfig[] = [
46
48
  {
@@ -72,10 +74,10 @@ export default function MaintenanceWorkerSearch() {
72
74
  ttl: 30_000,
73
75
  });
74
76
 
75
- const { filters, query, pagination, resetAll } = useObjectSearchParams<
77
+ const { filters, filterState, query, pagination, resetAll } = useObjectSearchParams<
76
78
  Maintenance_Worker__C_Filter,
77
79
  Maintenance_Worker__C_OrderBy
78
- >(FILTER_CONFIGS, SORT_CONFIGS, PAGINATION_CONFIG);
80
+ >(FILTER_CONFIGS, SORT_CONFIGS, PAGINATION_CONFIG, { filterSyncMode: "manual" });
79
81
 
80
82
  const searchKey = `maintenance-workers:${JSON.stringify({ where: query.where, orderBy: query.orderBy, first: pagination.pageSize, after: pagination.afterCursor })}`;
81
83
  const { data, loading, error } = useCachedAsyncData(
@@ -109,6 +111,7 @@ export default function MaintenanceWorkerSearch() {
109
111
  <PageHeader title="Maintenance Workers" description="View and filter maintenance workers" />
110
112
  <MaintenanceWorkerSearchFilters
111
113
  filters={filters}
114
+ filterState={filterState}
112
115
  typeOptions={typeOptions ?? []}
113
116
  resetAll={resetAll}
114
117
  />
@@ -163,6 +166,7 @@ export default function MaintenanceWorkerSearch() {
163
166
 
164
167
  function MaintenanceWorkerSearchFilters({
165
168
  filters,
169
+ filterState,
166
170
  typeOptions,
167
171
  resetAll,
168
172
  }: {
@@ -170,6 +174,10 @@ function MaintenanceWorkerSearchFilters({
170
174
  Maintenance_Worker__C_Filter,
171
175
  Maintenance_Worker__C_OrderBy
172
176
  >["filters"];
177
+ filterState: UseObjectSearchParamsReturn<
178
+ Maintenance_Worker__C_Filter,
179
+ Maintenance_Worker__C_OrderBy
180
+ >["filterState"];
173
181
  typeOptions: Array<{ value: string; label: string }>;
174
182
  resetAll: () => void;
175
183
  }) {
@@ -179,6 +187,9 @@ function MaintenanceWorkerSearchFilters({
179
187
  onFilterChange={filters.set}
180
188
  onFilterRemove={filters.remove}
181
189
  onReset={resetAll}
190
+ onApply={filterState.apply}
191
+ hasPendingChanges={filterState.hasPendingChanges}
192
+ hasValidationError={filterState.hasValidationError}
182
193
  >
183
194
  <FilterRow ariaLabel="Maintenance Workers filters">
184
195
  <SearchFilter
@@ -187,7 +198,7 @@ function MaintenanceWorkerSearchFilters({
187
198
  placeholder="By name, or phone..."
188
199
  className="w-full sm:w-50"
189
200
  />
190
- <SelectFilter
201
+ <MultiSelectFilter
191
202
  field="Employment_Type__c"
192
203
  label="Employment Type"
193
204
  options={typeOptions}
@@ -199,8 +210,15 @@ function MaintenanceWorkerSearchFilters({
199
210
  placeholder="Location"
200
211
  className="w-full sm:w-50"
201
212
  />
202
- <NumericRangeFilter field="Hourly_Rate__c" label="Hourly Rate" className="w-full sm:w-50" />
213
+ <NumericRangeFilter
214
+ field="Hourly_Rate__c"
215
+ label="Hourly Rate"
216
+ className="w-full sm:w-50"
217
+ minInputProps={nonNegativeNumberInputProps}
218
+ maxInputProps={nonNegativeNumberInputProps}
219
+ />
203
220
  <DateFilter field="CreatedDate" label="Created Date" className="w-full sm:w-56" />
221
+ <FilterApplyButton className="h-8 px-3 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 disabled:bg-purple-300 disabled:cursor-not-allowed transition-colors" />
204
222
  <FilterResetButton />
205
223
  </FilterRow>
206
224
  </FilterProvider>
@@ -17,12 +17,13 @@ import type { Property__C_Filter, Property__C_OrderBy } from "../api/graphql-ope
17
17
  import { PageHeader } from "../components/layout/PageHeader";
18
18
  import { PageContainer } from "../components/layout/PageContainer";
19
19
  import {
20
+ FilterApplyButton,
20
21
  FilterProvider,
21
22
  FilterResetButton,
22
23
  } from "../features/object-search/components/FilterContext";
23
24
  import { FilterRow } from "../components/layout/FilterRow";
24
25
  import { SearchFilter } from "../features/object-search/components/filters/SearchFilter";
25
- import { SelectFilter } from "../features/object-search/components/filters/SelectFilter";
26
+ import { MultiSelectFilter } from "../features/object-search/components/filters/MultiSelectFilter";
26
27
  import { NumericRangeFilter } from "../features/object-search/components/filters/NumericRangeFilter";
27
28
  import { ObjectSearchErrorState } from "../components/shared/ObjectSearchErrorState";
28
29
  import PaginationControls from "../features/object-search/components/PaginationControls";
@@ -30,6 +31,7 @@ import { PropertyCard } from "../components/properties/PropertyCard";
30
31
  import { PropertyDetailsModal } from "../components/properties/PropertyDetailsModal";
31
32
  import { Skeleton } from "../components/ui/skeleton";
32
33
  import { PAGINATION_CONFIG } from "../lib/constants";
34
+ import { nonNegativeNumberInputProps } from "../lib/filterUtils";
33
35
 
34
36
  const FILTER_CONFIGS: FilterFieldConfig[] = [
35
37
  {
@@ -65,10 +67,10 @@ export default function PropertySearch() {
65
67
  ttl: 30_000,
66
68
  });
67
69
 
68
- const { filters, query, pagination, resetAll } = useObjectSearchParams<
70
+ const { filters, filterState, query, pagination, resetAll } = useObjectSearchParams<
69
71
  Property__C_Filter,
70
72
  Property__C_OrderBy
71
- >(FILTER_CONFIGS, PROPERTY_SORT_CONFIGS, PAGINATION_CONFIG);
73
+ >(FILTER_CONFIGS, PROPERTY_SORT_CONFIGS, PAGINATION_CONFIG, { filterSyncMode: "manual" });
72
74
 
73
75
  const searchKey = `properties:${JSON.stringify({ where: query.where, orderBy: query.orderBy, first: pagination.pageSize, after: pagination.afterCursor })}`;
74
76
  const { data, loading, error } = useCachedAsyncData(
@@ -102,6 +104,7 @@ export default function PropertySearch() {
102
104
  <PageHeader title="Properties" description="Browse and manage available properties" />
103
105
  <PropertySearchFilters
104
106
  filters={filters}
107
+ filterState={filterState}
105
108
  statusOptions={statusOptions ?? []}
106
109
  typeOptions={typeOptions ?? []}
107
110
  resetAll={resetAll}
@@ -148,11 +151,13 @@ export default function PropertySearch() {
148
151
 
149
152
  function PropertySearchFilters({
150
153
  filters,
154
+ filterState,
151
155
  statusOptions,
152
156
  typeOptions,
153
157
  resetAll,
154
158
  }: {
155
159
  filters: UseObjectSearchParamsReturn<Property__C_Filter, Property__C_OrderBy>["filters"];
160
+ filterState: UseObjectSearchParamsReturn<Property__C_Filter, Property__C_OrderBy>["filterState"];
156
161
  statusOptions: Array<{ value: string; label: string }>;
157
162
  typeOptions: Array<{ value: string; label: string }>;
158
163
  resetAll: () => void;
@@ -163,6 +168,9 @@ function PropertySearchFilters({
163
168
  onFilterChange={filters.set}
164
169
  onFilterRemove={filters.remove}
165
170
  onReset={resetAll}
171
+ onApply={filterState.apply}
172
+ hasPendingChanges={filterState.hasPendingChanges}
173
+ hasValidationError={filterState.hasValidationError}
166
174
  >
167
175
  <FilterRow ariaLabel="Properties filters">
168
176
  <SearchFilter
@@ -171,13 +179,13 @@ function PropertySearchFilters({
171
179
  placeholder="Search by name or address..."
172
180
  className="w-full sm:w-50"
173
181
  />
174
- <SelectFilter
182
+ <MultiSelectFilter
175
183
  field="Status__c"
176
184
  label="Status"
177
185
  options={statusOptions}
178
186
  className="w-full sm:w-36"
179
187
  />
180
- <SelectFilter
188
+ <MultiSelectFilter
181
189
  field="Type__c"
182
190
  label="Type"
183
191
  options={typeOptions}
@@ -187,8 +195,17 @@ function PropertySearchFilters({
187
195
  field="Monthly_Rent__c"
188
196
  label="Monthly Rent"
189
197
  className="w-full sm:w-50"
198
+ minInputProps={nonNegativeNumberInputProps}
199
+ maxInputProps={nonNegativeNumberInputProps}
190
200
  />
191
- <NumericRangeFilter field="Bedrooms__c" label="Bedrooms" className="w-full sm:w-50" />
201
+ <NumericRangeFilter
202
+ field="Bedrooms__c"
203
+ label="Bedrooms"
204
+ className="w-full sm:w-50"
205
+ minInputProps={nonNegativeNumberInputProps}
206
+ maxInputProps={nonNegativeNumberInputProps}
207
+ />
208
+ <FilterApplyButton className="h-8 px-3 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 disabled:bg-purple-300 disabled:cursor-not-allowed transition-colors" />
192
209
  <FilterResetButton />
193
210
  </FilterRow>
194
211
  </FilterProvider>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "1.116.12",
3
+ "version": "1.116.13",
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.116.12",
9
+ "version": "1.116.13",
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.116.12",
3
+ "version": "1.116.13",
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-sample-b2e-experimental",
3
- "version": "1.116.12",
3
+ "version": "1.116.13",
4
4
  "description": "Salesforce sample property rental React app",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",
@@ -16,7 +16,7 @@
16
16
  "clean": "rm -rf dist"
17
17
  },
18
18
  "dependencies": {
19
- "@salesforce/webapp-experimental": "^1.116.12",
19
+ "@salesforce/webapp-experimental": "^1.116.13",
20
20
  "sonner": "^1.7.0"
21
21
  },
22
22
  "devDependencies": {