@salesforce/webapp-template-app-react-sample-b2e-experimental 1.116.11 → 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/AGENT.md CHANGED
@@ -1,87 +1,193 @@
1
- # Agent guide: SFDX project with React web app
1
+ # Agent guide: Salesforce web application development
2
2
 
3
- This project is a **Salesforce DX (SFDX) project** containing a **React web application**. The SFDX source path is defined in `sfdx-project.json` (`packageDirectories[].path`); the web app lives under `<sfdx-source>/webapplications/<appName>/`. Use this file when working in this directory.
3
+ This project is a **Salesforce DX (SFDX) project** containing a **React web application**. The web app is a standalone Vite + React SPA that runs inside the Salesforce platform. Use this file when working in this project.
4
4
 
5
- ## SFDX Source Path
5
+ ## Resolving paths
6
6
 
7
- The source path prefix is **not** always `force-app`. Read `sfdx-project.json` at the project root, take the first `packageDirectories[].path` value, and append `/main/default` to get `<sfdx-source>`. All paths below use this placeholder.
7
+ Read `sfdx-project.json` at the project root. Take the first `packageDirectories[].path` value and append `/main/default` to get `<sfdx-source>`. The web app directory is:
8
+
9
+ ```
10
+ <sfdx-source>/webapplications/<appName>/
11
+ ```
12
+
13
+ Replace `<appName>` with the actual folder name found under `webapplications/`. The source path is **not** always `force-app` — always resolve it from `sfdx-project.json`.
8
14
 
9
15
  ## Project layout
10
16
 
11
- - **Project root**: this directory — SFDX project root. Contains `sfdx-project.json`, the SFDX source directory, and (optionally) LWC/Aura.
12
- - **React web app**: `<sfdx-source>/webapplications/<appName>/`
13
- - Replace `<appName>` with the actual app folder name (e.g. `base-react-app`, or the name chosen when the app was generated).
14
- - Entry: `src/App.tsx`
15
- - Routes: `src/routes.tsx`
16
- - API/GraphQL: `src/api/` (e.g. `graphql.ts`, `graphql-operations-types.ts`, `utils/`)
17
+ ```
18
+ <project-root>/
19
+ ├── sfdx-project.json
20
+ ├── package.json # SFDX root scripts
21
+ ├── scripts/
22
+ │ ├── setup-cli.mjs # One-command setup (deploy, schema, build)
23
+ │ └── graphql-search.sh # Schema entity lookup
24
+ ├── config/
25
+ │ └── project-scratch-def.json
26
+
27
+ └── <sfdx-source>/
28
+ ├── webapplications/
29
+ │ └── <appName>/ # ← React web app (primary workspace)
30
+ │ ├── <appName>.webapplication-meta.xml
31
+ │ ├── webapplication.json
32
+ │ ├── index.html
33
+ │ ├── package.json
34
+ │ ├── vite.config.ts / tsconfig.json
35
+ │ ├── vitest.config.ts / playwright.config.ts
36
+ │ ├── codegen.yml / .graphqlrc.yml
37
+ │ └── src/ # All application code lives here
38
+
39
+ ├── classes/ # Apex classes (optional)
40
+ ├── objects/ # Custom objects and fields (optional)
41
+ ├── permissionsets/ # Permission sets (optional)
42
+ ├── cspTrustedSites/ # CSP trusted site definitions (optional)
43
+ ├── layouts/ # Object layouts (optional)
44
+ ├── triggers/ # Apex triggers (optional)
45
+ └── data/ # Sample data for import (optional)
46
+ ```
47
+
48
+ ## Web application source structure
17
49
 
18
- Path convention: **webapplications** (lowercase).
50
+ All application code lives inside the web app's `src/` directory:
51
+
52
+ ```
53
+ src/
54
+ ├── app.tsx # Entry point — creates the browser router
55
+ ├── appLayout.tsx # Shell layout (header, navigation, Outlet, footer)
56
+ ├── routes.tsx # Single route registry for the entire app
57
+ ├── navigationMenu.tsx # Navigation component
58
+ ├── router-utils.tsx # Router helpers
59
+ ├── lib/utils.ts # Utility functions (cn, etc.)
60
+ ├── styles/global.css # Tailwind global styles
61
+ ├── api/ # GraphQL operations, clients, data services
62
+ ├── assets/ # Static SVGs, images
63
+ ├── components/
64
+ │ ├── ui/ # Shared primitives (shadcn-style: button, card, input, etc.)
65
+ │ ├── layout/ # Layout components (header, footer, sidebar)
66
+ │ └── <feature>/ # Feature-specific components
67
+ ├── features/ # Feature modules (auth, search, etc.)
68
+ ├── hooks/ # Custom React hooks
69
+ ├── pages/ # Page components (one per route)
70
+ ├── public/ # Static assets served as-is
71
+ └── utils/ # Shared utilities
72
+ ```
73
+
74
+ ### Key files
75
+
76
+ | File | Role |
77
+ |------|------|
78
+ | `app.tsx` | Creates `BrowserRouter`; do not add UI here |
79
+ | `appLayout.tsx` | Source of truth for navigation, header, footer, and page shell |
80
+ | `routes.tsx` | Single route registry; all pages are children of the layout route |
81
+ | `<appName>.webapplication-meta.xml` | Salesforce deploy descriptor (`masterLabel`, `version`, `isActive`) |
82
+ | `webapplication.json` | Runtime config (`outputDir`, routing) |
19
83
 
20
84
  ## Two package.json contexts
21
85
 
22
- ### 1. Project root (this directory)
86
+ ### 1. Project root
23
87
 
24
- Used for SFDX metadata (LWC, Aura, etc.). Scripts here are for the base SFDX template:
88
+ Used for SFDX metadata tooling. Scripts here target LWC/Aura, not the React app.
25
89
 
26
90
  | Command | Purpose |
27
91
  |---------|---------|
28
- | `npm run lint` | ESLint for `aura/` and `lwc/` |
29
92
  | `npm run test` | LWC Jest (passWithNoTests) |
30
- | `npm run prettier` | Format supported metadata files |
93
+ | `npm run prettier` | Format metadata files |
31
94
  | `npm run prettier:verify` | Check Prettier |
32
95
 
33
- **One-command setup:** From project root run `node scripts/setup-cli.mjs --target-org <alias>` to run login (if needed), deploy, optional permset/data import, GraphQL schema/codegen, web app build, and optionally the dev server. Use `node scripts/setup-cli.mjs --help` for options (e.g. `--skip-login`, `--skip-data`, `--webapp-name`).
34
-
35
- Root **does not** run the React app. The root `npm run build` is a no-op for the base SFDX project.
96
+ **One-command setup:** `node scripts/setup-cli.mjs --target-org <alias>` runs login, deploy, permset assignment, data import, GraphQL schema/codegen, web app build, and optionally the dev server. Use `--help` for all flags.
36
97
 
37
- ### 2. React web app (where you do most work)
98
+ ### 2. Web app directory (primary workspace)
38
99
 
39
100
  **Always `cd` into the web app directory for dev/build/lint/test:**
40
101
 
41
- ```bash
42
- cd <sfdx-source>/webapplications/<appName>
43
- ```
44
-
45
102
  | Command | Purpose |
46
103
  |---------|---------|
47
104
  | `npm run dev` | Start Vite dev server |
48
- | `npm run build` | TypeScript (`tsc -b`) + Vite build |
105
+ | `npm run build` | TypeScript check + Vite production build |
49
106
  | `npm run lint` | ESLint for the React app |
50
- | `npm run test` | Vitest |
107
+ | `npm run test` | Vitest unit tests |
51
108
  | `npm run preview` | Preview production build |
52
- | `npm run graphql:codegen` | Generate GraphQL types |
53
- | `npm run graphql:schema` | Fetch GraphQL schema |
109
+ | `npm run graphql:codegen` | Generate GraphQL types from schema |
110
+ | `npm run graphql:schema` | Fetch GraphQL schema from org |
111
+
112
+ **Before completing any change:** run `npm run build` and `npm run lint` from the web app directory. Both must pass with zero errors.
113
+
114
+ ## Development conventions
115
+
116
+ ### UI
117
+
118
+ - **Component library:** shadcn/ui primitives in `src/components/ui/`. Always use these over raw HTML equivalents.
119
+ - **Styling:** Tailwind CSS only. No inline `style={{}}`. Use `cn()` from `@/lib/utils` for conditional classes.
120
+ - **Icons:** Lucide React.
121
+ - **Path alias:** `@/*` maps to `src/*`. Use it for all imports.
122
+ - **TypeScript:** No `any`. Use proper types, generics, or `unknown`.
123
+ - **Components:** Accept `className?: string` prop. Extract shared state to custom hooks in `src/hooks/`.
124
+ - **React apps must not** import Salesforce platform modules (`lightning/*`, `@wire`, LWC APIs).
125
+
126
+ ### Routing
54
127
 
55
- **Before finishing changes:** run `npm run build` and `npm run lint` from the web app directory; both must succeed.
128
+ - React Router with `createBrowserRouter`. Route definitions live exclusively in `routes.tsx`.
129
+ - All page routes are children of the layout route (which renders `appLayout.tsx`).
130
+ - Default-export one component per page file.
131
+ - The catch-all `path: '*'` route must always be last.
132
+ - Navigation uses absolute paths (`/dashboard`). Non-router imports use dot-relative paths (`./utils`).
133
+ - Navigation visibility is driven by `handle.showInNavigation` on route definitions.
56
134
 
57
- ## Agent rules (.a4drules/)
135
+ ### Layout and navigation
58
136
 
59
- Markdown rules at the project root under **.a4drules/** define platform constraints:
137
+ - `appLayout.tsx` owns the header, navigation menu, footer, and `<Outlet />`.
138
+ - To modify header or footer, edit `appLayout.tsx` and create components in `src/components/layout/`.
139
+ - To add a page, add a route in `routes.tsx` and create the page component — do not modify `appLayout.tsx` or `app.tsx` for page additions.
60
140
 
61
- - **`.a4drules/webapp-ui.md`** Salesforce Web Application UI (scaffold with `sf webapp generate`, no LWC/Aura for new UI).
62
- - **`.a4drules/webapp-data.md`** — Salesforce data access (Data SDK only, supported APIs, GraphQL workflow, `scripts/graphql-search.sh` for schema lookup).
141
+ ### Data access (Salesforce)
63
142
 
64
- When rules refer to "web app directory" or `<sfdx-source>/webapplications/<appName>/`, resolve `<sfdx-source>` from `sfdx-project.json` and use the **actual app folder name** for this project.
143
+ - **All data access uses the Data SDK** (`@salesforce/sdk-data`) via `createDataSDK()`.
144
+ - **Never** use `fetch()` or `axios` directly for Salesforce data.
145
+ - **GraphQL is preferred** for record operations (`sdk.graphql`). Use `sdk.fetch` only when GraphQL cannot cover the case (UI API REST, Apex REST, Connect REST, Einstein LLM).
146
+ - Use optional chaining: `sdk.graphql?.()`, `sdk.fetch?.()`.
147
+ - Apply the `@optional` directive to all record fields for field-level security resilience.
148
+ - Verify field and object names via `scripts/graphql-search.sh` before writing queries.
149
+ - Use `__SF_API_VERSION__` global for API version in REST calls.
150
+ - **Blocked APIs:** Enterprise REST query endpoint (`/query` with SOQL), `@AuraEnabled` Apex, Chatter API.
151
+
152
+ ### CSP trusted sites
153
+
154
+ Any external domain the app calls (APIs, CDNs, fonts) must have a `.cspTrustedSite-meta.xml` file under `<sfdx-source>/cspTrustedSites/`. Unregistered domains are blocked at runtime. Each subdomain needs its own entry. URLs must be HTTPS with no trailing slash, no path, and no wildcards.
65
155
 
66
156
  ## Deploying
67
157
 
68
- **Deployment order:** Metadata (objects, permission sets) must be deployed before GraphQL schema fetch. After any metadata deployment, re-run `npm run graphql:schema` and `npm run graphql:codegen` from the webapp dir. **One-command setup:** `node scripts/setup-cli.mjs --target-org <alias>` runs deploy → permset → schema → codegen in the correct order.
158
+ **Deployment order matters.** Metadata (objects, permission sets) must be deployed before fetching the GraphQL schema. After any metadata deployment that changes objects, fields, or permissions, re-run schema fetch and codegen.
69
159
 
70
- From **this project root** (resolve the actual SFDX source path from `sfdx-project.json`):
160
+ **Recommended sequence:**
71
161
 
72
- ```bash
73
- # Build the React app first (replace <sfdx-source> and <appName> with actual values)
74
- cd <sfdx-source>/webapplications/<appName> && npm i && npm run build && cd -
162
+ 1. Authenticate to the target org
163
+ 2. Build the web app (`npm run build` in the web app directory)
164
+ 3. Deploy metadata (`sf project deploy start --source-dir <packageDir> --target-org <alias>`)
165
+ 4. Assign permission sets
166
+ 5. Import data (only with user confirmation)
167
+ 6. Fetch GraphQL schema + run codegen (`npm run graphql:schema && npm run graphql:codegen`)
168
+ 7. Rebuild the web app (schema changes may affect generated types)
169
+
170
+ **Or use the one-command setup:** `node scripts/setup-cli.mjs --target-org <alias>`
75
171
 
76
- # Deploy web app only (replace <sfdx-source> with actual path, e.g. force-app/main/default)
172
+ ```bash
173
+ # Deploy web app only
77
174
  sf project deploy start --source-dir <sfdx-source>/webapplications --target-org <alias>
78
175
 
79
- # Deploy all metadata (use the top-level package directory, e.g. force-app)
176
+ # Deploy all metadata
80
177
  sf project deploy start --source-dir <packageDir> --target-org <alias>
81
178
  ```
82
179
 
83
- ## Conventions (quick reference)
180
+ ## Skills
181
+
182
+ Check for available skills before implementing any of the following:
183
+
184
+ | Area | When to consult |
185
+ |------|----------------|
186
+ | UI generation | Building pages, components, modifying header/footer/layout |
187
+ | Salesforce data access | Reading/writing records, GraphQL queries, REST calls |
188
+ | Metadata and deployment | Scaffolding apps, configuring CSP, deployment sequencing |
189
+ | Feature installation | Before building something from scratch — check if a pre-built feature exists |
190
+ | File upload | Adding file upload with Salesforce ContentVersion |
191
+ | Agentforce conversation | Adding or modifying the Agentforce chat widget |
84
192
 
85
- - **UI**: shadcn/ui + Tailwind. Import from `@/components/ui/...`.
86
- - **Entry**: Keep `App.tsx` and routes in `src/`; add features as new routes or sections, don't replace the app shell but you may modify it to match the requested design.
87
- - **Data (Salesforce)**: Follow `.a4drules/webapp-data.md` for all Salesforce data access. Use the Data SDK (`createDataSDK()` + `sdk.graphql` or `sdk.fetch`) — never use `fetch` or `axios` directly. GraphQL is preferred; use `sdk.fetch` when GraphQL is not sufficient.
193
+ Skills are the authoritative source for detailed patterns, constraints, and code examples in each area. This file provides project-level orientation; skills provide implementation depth.
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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
+
14
+ ## [1.116.12](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.11...v1.116.12) (2026-03-27)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * updating AGENT.md to focus on web application development ([#366](https://github.com/salesforce-experience-platform-emu/webapps/issues/366)) ([59b94d7](https://github.com/salesforce-experience-platform-emu/webapps/commit/59b94d7b995042051e1622c78f8cd562b6f99244))
20
+
21
+
22
+
23
+
24
+
6
25
  ## [1.116.11](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.10...v1.116.11) (2026-03-27)
7
26
 
8
27
  **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.116.11",
19
- "@salesforce/webapp-experimental": "^1.116.11",
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.11",
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.11",
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.11",
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.11",
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.11",
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.11",
19
+ "@salesforce/webapp-experimental": "^1.116.13",
20
20
  "sonner": "^1.7.0"
21
21
  },
22
22
  "devDependencies": {