@salesforce/webapp-template-app-react-sample-b2x-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",
@@ -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.116.12",
50
+ "@salesforce/vite-plugin-webapp-experimental": "^1.116.13",
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",
@@ -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,
@@ -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-b2x-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": "",