@salesforce/webapp-template-app-react-template-b2e-experimental 1.112.5 → 1.112.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/.a4drules/webapp-data.md +5 -5
  2. package/dist/.a4drules/webapp-ui.md +2 -2
  3. package/dist/AGENT.md +6 -10
  4. package/dist/CHANGELOG.md +16 -0
  5. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package.json +3 -3
  6. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/graphqlClient.ts +25 -0
  7. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/pages/AccountSearch.tsx +82 -54
  8. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/FilterContext.tsx +73 -0
  9. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/PaginationControls.tsx +45 -87
  10. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/BooleanFilter.tsx +16 -36
  11. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/DateFilter.tsx +33 -77
  12. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/DateRangeFilter.tsx +14 -23
  13. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/MultiSelectFilter.tsx +18 -26
  14. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/NumericRangeFilter.tsx +22 -39
  15. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/SearchFilter.tsx +12 -15
  16. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/SelectFilter.tsx +30 -34
  17. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/TextFilter.tsx +27 -30
  18. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useAsyncData.ts +1 -0
  19. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useCachedAsyncData.ts +1 -0
  20. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useObjectSearchParams.ts +22 -0
  21. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/routes.tsx +0 -17
  22. package/dist/package-lock.json +2 -2
  23. package/dist/package.json +1 -1
  24. package/dist/{.a4drules/skills/using-salesforce-data → scripts}/graphql-search.sh +4 -4
  25. package/package.json +1 -1
  26. package/dist/.a4drules/skills/building-data-visualization/SKILL.md +0 -72
  27. package/dist/.a4drules/skills/building-data-visualization/implementation/bar-line-chart.md +0 -316
  28. package/dist/.a4drules/skills/building-data-visualization/implementation/dashboard-layout.md +0 -189
  29. package/dist/.a4drules/skills/building-data-visualization/implementation/donut-chart.md +0 -181
  30. package/dist/.a4drules/skills/building-data-visualization/implementation/stat-card.md +0 -150
  31. package/dist/.a4drules/skills/building-react-components/SKILL.md +0 -96
  32. package/dist/.a4drules/skills/building-react-components/implementation/component.md +0 -78
  33. package/dist/.a4drules/skills/building-react-components/implementation/header-footer.md +0 -132
  34. package/dist/.a4drules/skills/building-react-components/implementation/page.md +0 -93
  35. package/dist/.a4drules/skills/configuring-csp-trusted-sites/SKILL.md +0 -90
  36. package/dist/.a4drules/skills/configuring-csp-trusted-sites/implementation/metadata-format.md +0 -281
  37. package/dist/.a4drules/skills/configuring-webapp-metadata/SKILL.md +0 -158
  38. package/dist/.a4drules/skills/creating-webapp/SKILL.md +0 -140
  39. package/dist/.a4drules/skills/deploying-to-salesforce/SKILL.md +0 -226
  40. package/dist/.a4drules/skills/implementing-file-upload/SKILL.md +0 -396
  41. package/dist/.a4drules/skills/installing-webapp-features/SKILL.md +0 -210
  42. package/dist/.a4drules/skills/managing-agentforce-conversation-client/SKILL.md +0 -186
  43. package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/constraints.md +0 -134
  44. package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/examples.md +0 -132
  45. package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/style-tokens.md +0 -101
  46. package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/troubleshooting.md +0 -57
  47. package/dist/.a4drules/skills/using-salesforce-data/SKILL.md +0 -363
  48. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/FilterPanel.tsx +0 -127
  49. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/TestAccPage.tsx +0 -19
@@ -74,10 +74,10 @@ Map user intent to PascalCase names ("accounts" → `Account`), then **run the s
74
74
 
75
75
  ```bash
76
76
  # From project root — look up all relevant schema info for one or more entities
77
- bash .a4drules/skills/using-salesforce-data/graphql-search.sh Account
77
+ bash scripts/graphql-search.sh Account
78
78
 
79
79
  # Multiple entities at once
80
- bash .a4drules/skills/using-salesforce-data/graphql-search.sh Account Contact Opportunity
80
+ bash scripts/graphql-search.sh Account Contact Opportunity
81
81
  ```
82
82
 
83
83
  The script outputs five sections per entity:
@@ -213,7 +213,7 @@ const fields = response?.data?.uiapi?.objectInfos?.[0]?.fields ?? [];
213
213
 
214
214
  ```bash
215
215
  # From project root — re-check the entity that caused the error
216
- bash .a4drules/skills/using-salesforce-data/graphql-search.sh <EntityName>
216
+ bash scripts/graphql-search.sh <EntityName>
217
217
  ```
218
218
 
219
219
  Then fix the query using the exact names from the script output.
@@ -310,7 +310,7 @@ const response = await sdk.graphql?.(GET_CURRENT_USER);
310
310
  |---------|----------|-----|
311
311
  | `npm run graphql:schema` | webapp dir | Script in webapp's package.json |
312
312
  | `npx eslint <file>` | webapp dir | Reads eslint.config.js |
313
- | `bash .a4drules/skills/using-salesforce-data/graphql-search.sh <Entity>` | project root | Schema lookup |
313
+ | `bash scripts/graphql-search.sh <Entity>` | project root | Schema lookup |
314
314
  | `sf api request rest` | project root | Needs sfdx-project.json |
315
315
 
316
316
  ---
@@ -322,7 +322,7 @@ const response = await sdk.graphql?.(GET_CURRENT_USER);
322
322
  Run the search script to get all relevant schema info in one step:
323
323
 
324
324
  ```bash
325
- bash .a4drules/skills/using-salesforce-data/graphql-search.sh <EntityName>
325
+ bash scripts/graphql-search.sh <EntityName>
326
326
  ```
327
327
 
328
328
  | Script Output Section | Used For |
@@ -8,9 +8,9 @@ When any task involves creating a new UI, frontend, page, dashboard, form, or us
8
8
  2. The app must live under `<sfdx-source>/webapplications/<AppName>/` as a WebApplication bundle.
9
9
  3. Do not build new UIs as LWC components, Aura components, or Visualforce pages.
10
10
 
11
- Invoke the `creating-webapp` skill (`.a4drules/skills/creating-webapp/`) for the full setup workflow.
11
+ For setup from the project root, use `node scripts/setup-cli.mjs --help` and the options documented there.
12
12
 
13
13
  ## Data Access (MUST FOLLOW)
14
14
 
15
15
  - **Never hardcode data in the app.** All data displayed in the UI must come from live Salesforce data fetching — no static arrays, mock objects, or placeholder values in production code.
16
- - **Always invoke the `using-salesforce-data` skill** (`.a4drules/skills/using-salesforce-data/`) before writing any data access code. All implementation must be derived from that skill.
16
+ - **Follow `.a4drules/webapp-data.md`** before writing any data access code. All implementation must match those rules (Data SDK, supported APIs, GraphQL workflow).
package/dist/AGENT.md CHANGED
@@ -54,22 +54,18 @@ cd <sfdx-source>/webapplications/<appName>
54
54
 
55
55
  **Before finishing changes:** run `npm run build` and `npm run lint` from the web app directory; both must succeed.
56
56
 
57
- ## Agent skills (.a4drules/skills/)
57
+ ## Agent rules (.a4drules/)
58
58
 
59
- This project includes **.a4drules/skills/** at the project root. Follow them when generating or editing code.
59
+ Markdown rules at the project root under **.a4drules/** define platform constraints:
60
60
 
61
- - **Creating Webapp** (`.a4drules/skills/creating-webapp/`): First steps, skills-first protocol, React/TypeScript constraints, deployment order, navigation, aesthetics, and code quality.
62
- - **Building React Components** (`.a4drules/skills/building-react-components/`): Component/page/header-footer workflow, TypeScript standards, and mandatory lint+build verification.
63
- - **Accessing Data** (`.a4drules/skills/accessing-data/`): Use for all Salesforce data fetches. Enforces Data SDK usage (`createDataSDK()` + `sdk.graphql` or `sdk.fetch`); GraphQL preferred, fetch when GraphQL is not sufficient.
64
- - **Fetching REST API** (`.a4drules/skills/fetching-rest-api/`): Use when implementing Chatter, Connect REST, Apex REST, UI API REST, or Einstein LLM calls via `sdk.fetch`.
65
- - **Using GraphQL** (`.a4drules/skills/using-graphql/`): Use when implementing Salesforce GraphQL queries or mutations. Sub-skills: `exploring-graphql-schema`, `generating-graphql-read-query`, `generating-graphql-mutation-query`.
66
- - **Deploying to Salesforce** (`.a4drules/skills/deploying-to-salesforce/`): Use when deploying metadata, fetching GraphQL schema, or generating deploy/setup commands. Enforces deploy → permset → schema → codegen order; schema refetch after metadata deployment.
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).
67
63
 
68
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.
69
65
 
70
66
  ## Deploying
71
67
 
72
- **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. Invoke the `deploying-to-salesforce` skill for full guidance.
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.
73
69
 
74
70
  From **this project root** (resolve the actual SFDX source path from `sfdx-project.json`):
75
71
 
@@ -88,4 +84,4 @@ sf project deploy start --source-dir <packageDir> --target-org <alias>
88
84
 
89
85
  - **UI**: shadcn/ui + Tailwind. Import from `@/components/ui/...`.
90
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.
91
- - **Data (Salesforce)**: Invoke the `accessing-data` skill for all Salesforce data fetches. The skill enforces use of 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 (e.g., Chatter, Connect REST). For GraphQL implementation, invoke the `using-graphql` skill.
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.
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.112.7](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.112.6...v1.112.7) (2026-03-23)
7
+
8
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
9
+
10
+
11
+
12
+
13
+
14
+ ## [1.112.6](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.112.5...v1.112.6) (2026-03-23)
15
+
16
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
17
+
18
+
19
+
20
+
21
+
6
22
  ## [1.112.5](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.112.4...v1.112.5) (2026-03-21)
7
23
 
8
24
  **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
@@ -15,8 +15,8 @@
15
15
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@salesforce/sdk-data": "^1.112.5",
19
- "@salesforce/webapp-experimental": "^1.112.5",
18
+ "@salesforce/sdk-data": "^1.112.7",
19
+ "@salesforce/webapp-experimental": "^1.112.7",
20
20
  "@tailwindcss/vite": "^4.1.17",
21
21
  "class-variance-authority": "^0.7.1",
22
22
  "clsx": "^2.1.1",
@@ -42,7 +42,7 @@
42
42
  "@graphql-eslint/eslint-plugin": "^4.1.0",
43
43
  "@graphql-tools/utils": "^11.0.0",
44
44
  "@playwright/test": "^1.49.0",
45
- "@salesforce/vite-plugin-webapp-experimental": "^1.112.5",
45
+ "@salesforce/vite-plugin-webapp-experimental": "^1.112.7",
46
46
  "@testing-library/jest-dom": "^6.6.3",
47
47
  "@testing-library/react": "^16.1.0",
48
48
  "@testing-library/user-event": "^14.5.2",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Thin GraphQL client: createDataSDK + data.graphql with centralized error handling.
3
+ * Use with gql-tagged queries and generated operation types for type-safe calls.
4
+ */
5
+ import { createDataSDK } from '@salesforce/sdk-data';
6
+
7
+ export async function executeGraphQL<TData, TVariables>(
8
+ query: string,
9
+ variables?: TVariables
10
+ ): Promise<TData> {
11
+ const data = await createDataSDK();
12
+ // SDK types graphql() first param as string; at runtime it may accept gql DocumentNode too
13
+ const response = await data.graphql?.<TData, TVariables>(query, variables);
14
+
15
+ if (!response) {
16
+ throw new Error('GraphQL response is undefined');
17
+ }
18
+
19
+ if (response?.errors?.length) {
20
+ const msg = response.errors.map(e => e.message).join('; ');
21
+ throw new Error(`GraphQL Error: ${msg}`);
22
+ }
23
+
24
+ return response.data;
25
+ }
@@ -1,6 +1,6 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useState } from "react";
2
2
  import { Link } from "react-router";
3
- import { AlertCircle, SearchX } from "lucide-react";
3
+ import { AlertCircle, ChevronDown, SearchX } from "lucide-react";
4
4
  import {
5
5
  searchAccounts,
6
6
  fetchDistinctIndustries,
@@ -10,8 +10,27 @@ import { useCachedAsyncData } from "../../hooks/useCachedAsyncData";
10
10
  import { fieldValue } from "../../utils/fieldUtils";
11
11
  import { useObjectSearchParams } from "../../hooks/useObjectSearchParams";
12
12
  import { Alert, AlertTitle, AlertDescription } from "../../../../components/ui/alert";
13
+ import {
14
+ Card,
15
+ CardContent,
16
+ CardHeader,
17
+ CardTitle,
18
+ } from "../../../../components/ui/card";
19
+ import { Button } from "../../../../components/ui/button";
20
+ import {
21
+ Collapsible,
22
+ CollapsibleContent,
23
+ CollapsibleTrigger,
24
+ } from "../../../../components/ui/collapsible";
13
25
  import { Skeleton } from "../../../../components/ui/skeleton";
14
- import { FilterPanel } from "../../components/FilterPanel";
26
+ import { FilterProvider, FilterResetButton } from "../../components/FilterContext";
27
+ import { SearchFilter } from "../../components/filters/SearchFilter";
28
+ import { TextFilter } from "../../components/filters/TextFilter";
29
+ import { SelectFilter } from "../../components/filters/SelectFilter";
30
+ import { MultiSelectFilter } from "../../components/filters/MultiSelectFilter";
31
+ import { NumericRangeFilter } from "../../components/filters/NumericRangeFilter";
32
+ import { DateFilter } from "../../components/filters/DateFilter";
33
+ import { DateRangeFilter } from "../../components/filters/DateRangeFilter";
15
34
  import { ActiveFilters } from "../../components/ActiveFilters";
16
35
  import { SortControl } from "../../components/SortControl";
17
36
  import type { FilterFieldConfig } from "../../utils/filterUtils";
@@ -31,48 +50,21 @@ type AccountNode = NonNullable<
31
50
  NonNullable<NonNullable<AccountSearchResult["edges"]>[number]>["node"]
32
51
  >;
33
52
 
34
- // -- Configuration ----------------------------------------------------------
35
- // Adding a new filterable field = adding one entry here. No component changes needed.
36
- // Picklist options are fetched dynamically via aggregate groupBy queries.
37
-
38
- function buildAccountFilterConfigs(
39
- industryOptions: Array<{ value: string; label: string }>,
40
- typeOptions: Array<{ value: string; label: string }>,
41
- ): FilterFieldConfig[] {
42
- return [
43
- {
44
- field: "search",
45
- label: "Search",
46
- type: "search",
47
- searchFields: ["Name", "Phone", "Industry"],
48
- placeholder: "Search by name, phone, or industry...",
49
- },
50
- {
51
- field: "Name",
52
- label: "Account Name",
53
- type: "text",
54
- placeholder: "Search by name...",
55
- },
56
- {
57
- field: "Industry",
58
- label: "Industry",
59
- type: "picklist",
60
- options: industryOptions,
61
- },
62
- { field: "Type", label: "Type", type: "multipicklist", options: typeOptions },
63
- { field: "AnnualRevenue", label: "Annual Revenue", type: "numeric" },
64
- {
65
- field: "CreatedDate",
66
- label: "Created Date",
67
- type: "date",
68
- },
69
- {
70
- field: "LastModifiedDate",
71
- label: "Last Modified Date",
72
- type: "daterange",
73
- },
74
- ];
75
- }
53
+ const FILTER_CONFIGS: FilterFieldConfig[] = [
54
+ {
55
+ field: "search",
56
+ label: "Search",
57
+ type: "search",
58
+ searchFields: ["Name", "Phone", "Industry"],
59
+ placeholder: "Search by name, phone, or industry...",
60
+ },
61
+ { field: "Name", label: "Account Name", type: "text", placeholder: "Search by name..." },
62
+ { field: "Industry", label: "Industry", type: "picklist" },
63
+ { field: "Type", label: "Type", type: "multipicklist" },
64
+ { field: "AnnualRevenue", label: "Annual Revenue", type: "numeric" },
65
+ { field: "CreatedDate", label: "Created Date", type: "date" },
66
+ { field: "LastModifiedDate", label: "Last Modified Date", type: "daterange" },
67
+ ];
76
68
 
77
69
  const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
78
70
  { field: "Name", label: "Name" },
@@ -84,6 +76,7 @@ const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
84
76
  // -- Component --------------------------------------------------------------
85
77
 
86
78
  export default function AccountSearch() {
79
+ const [filtersOpen, setFiltersOpen] = useState(true);
87
80
  const { data: industryOptions } = useCachedAsyncData(fetchDistinctIndustries, [], {
88
81
  key: "distinctIndustries",
89
82
  ttl: 300_000,
@@ -93,15 +86,10 @@ export default function AccountSearch() {
93
86
  ttl: 300_000,
94
87
  });
95
88
 
96
- const filterConfigs = useMemo(
97
- () => buildAccountFilterConfigs(industryOptions ?? [], typeOptions ?? []),
98
- [industryOptions, typeOptions],
99
- );
100
-
101
89
  const { filters, sort, query, pagination, resetAll } = useObjectSearchParams<
102
90
  Account_Filter,
103
91
  Account_OrderBy
104
- >(filterConfigs, ACCOUNT_SORT_CONFIGS, PAGINATION_CONFIG);
92
+ >(FILTER_CONFIGS, ACCOUNT_SORT_CONFIGS, PAGINATION_CONFIG);
105
93
 
106
94
  const searchKey = `accounts:${JSON.stringify({ where: query.where, orderBy: query.orderBy, first: pagination.pageSize, after: pagination.afterCursor })}`;
107
95
  const { data, loading, error } = useCachedAsyncData(
@@ -139,12 +127,52 @@ export default function AccountSearch() {
139
127
  <div className="flex flex-col lg:flex-row gap-6">
140
128
  {/* Sidebar — Filter Panel */}
141
129
  <aside className="w-full lg:w-80 shrink-0">
142
- <FilterPanel
143
- configs={filterConfigs}
130
+ <FilterProvider
144
131
  filters={filters.active}
145
132
  onFilterChange={filters.set}
133
+ onFilterRemove={filters.remove}
146
134
  onReset={resetAll}
147
- />
135
+ >
136
+ <Card>
137
+ <Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
138
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
139
+ <CardTitle className="text-base font-semibold">
140
+ <h2>Filters</h2>
141
+ </CardTitle>
142
+ <div className="flex items-center gap-1">
143
+ <FilterResetButton variant="destructive" size="sm" />
144
+ <CollapsibleTrigger asChild>
145
+ <Button variant="ghost" size="icon">
146
+ <ChevronDown
147
+ className={`h-4 w-4 transition-transform ${filtersOpen ? "" : "-rotate-90"}`}
148
+ />
149
+ <span className="sr-only">Toggle filters</span>
150
+ </Button>
151
+ </CollapsibleTrigger>
152
+ </div>
153
+ </CardHeader>
154
+ <CollapsibleContent>
155
+ <CardContent className="space-y-4 pt-0">
156
+ <SearchFilter
157
+ field="search"
158
+ label="Search"
159
+ placeholder="Search by name, phone, or industry..."
160
+ />
161
+ <TextFilter field="Name" label="Account Name" placeholder="Search by name..." />
162
+ <SelectFilter
163
+ field="Industry"
164
+ label="Industry"
165
+ options={industryOptions ?? []}
166
+ />
167
+ <MultiSelectFilter field="Type" label="Type" options={typeOptions ?? []} />
168
+ <NumericRangeFilter field="AnnualRevenue" label="Annual Revenue" />
169
+ <DateFilter field="CreatedDate" label="Created Date" />
170
+ <DateRangeFilter field="LastModifiedDate" label="Last Modified Date" />
171
+ </CardContent>
172
+ </CollapsibleContent>
173
+ </Collapsible>
174
+ </Card>
175
+ </FilterProvider>
148
176
  </aside>
149
177
 
150
178
  {/* Main area — Sort + Results */}
@@ -0,0 +1,73 @@
1
+ import { createContext, useContext, useCallback, type ReactNode } from "react";
2
+ import { Button } from "../../../components/ui/button";
3
+ import type { ActiveFilterValue } from "../utils/filterUtils";
4
+
5
+ interface FilterContextValue {
6
+ filters: ActiveFilterValue[];
7
+ onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
8
+ onFilterRemove: (field: string) => void;
9
+ onReset: () => void;
10
+ }
11
+
12
+ const FilterContext = createContext<FilterContextValue | null>(null);
13
+
14
+ interface FilterProviderProps {
15
+ filters: ActiveFilterValue[];
16
+ onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
17
+ onFilterRemove: (field: string) => void;
18
+ onReset: () => void;
19
+ children: ReactNode;
20
+ }
21
+
22
+ export function FilterProvider({
23
+ filters,
24
+ onFilterChange,
25
+ onFilterRemove,
26
+ onReset,
27
+ children,
28
+ }: FilterProviderProps) {
29
+ return (
30
+ <FilterContext.Provider value={{ filters, onFilterChange, onFilterRemove, onReset }}>
31
+ {children}
32
+ </FilterContext.Provider>
33
+ );
34
+ }
35
+
36
+ function useFilterContext() {
37
+ const ctx = useContext(FilterContext);
38
+ if (!ctx) throw new Error("useFilterField must be used within a FilterProvider");
39
+ return ctx;
40
+ }
41
+
42
+ export function useFilterField(field: string) {
43
+ const { filters, onFilterChange, onFilterRemove } = useFilterContext();
44
+ const value = filters.find((f) => f.field === field);
45
+ const onChange = useCallback(
46
+ (next: ActiveFilterValue | undefined) => {
47
+ if (next) {
48
+ onFilterChange(field, next);
49
+ } else {
50
+ onFilterRemove(field);
51
+ }
52
+ },
53
+ [field, onFilterChange, onFilterRemove],
54
+ );
55
+ return { value, onChange };
56
+ }
57
+
58
+ export function useFilterPanel() {
59
+ const { filters, onReset } = useFilterContext();
60
+ return { hasActiveFilters: filters.length > 0, resetAll: onReset };
61
+ }
62
+
63
+ interface FilterResetButtonProps extends Omit<React.ComponentProps<typeof Button>, "onClick"> {}
64
+
65
+ export function FilterResetButton({ children, ...props }: FilterResetButtonProps) {
66
+ const { hasActiveFilters, resetAll } = useFilterPanel();
67
+ if (!hasActiveFilters) return null;
68
+ return (
69
+ <Button onClick={resetAll} aria-label="Reset filters" variant="destructive" {...props}>
70
+ {children ?? "Reset"}
71
+ </Button>
72
+ );
73
+ }
@@ -1,4 +1,3 @@
1
- import type { ReactNode } from "react";
2
1
  import {
3
2
  Pagination,
4
3
  PaginationContent,
@@ -14,112 +13,71 @@ import {
14
13
  SelectValue,
15
14
  } from "../../../components/ui/select";
16
15
  import { Label } from "../../../components/ui/label";
17
- import { Button } from "../../../components/ui/button";
18
16
 
19
- /** Shared props: pagination state is from useObjectSearchParams (synced to URL). */
20
- interface PaginationControlsBase {
21
- pageSize: number;
22
- pageSizeOptions: readonly number[];
23
- onPageSizeChange: (newPageSize: number) => void;
24
- disabled?: boolean;
25
- }
26
-
27
- /** Default mode: Previous, Page N, Next (cursor-based, state in URL). */
28
- interface PaginationControlsDefaultProps extends PaginationControlsBase {
29
- variant?: "default";
17
+ interface PaginationControlsProps {
30
18
  pageIndex: number;
31
19
  hasNextPage: boolean;
32
20
  hasPreviousPage: boolean;
21
+ pageSize: number;
22
+ pageSizeOptions: readonly number[];
33
23
  onNextPage: () => void;
34
24
  onPreviousPage: () => void;
25
+ onPageSizeChange: (newPageSize: number) => void;
26
+ disabled?: boolean;
35
27
  }
36
28
 
37
- /** Load More mode: optional page size + Load More button (or custom slot). */
38
- interface PaginationControlsLoadMoreProps extends PaginationControlsBase {
39
- variant: "loadMore";
40
- hasNextPage: boolean;
41
- onLoadMore: () => void;
42
- loadMoreLoading?: boolean;
43
- /** Custom content for load-more (e.g. custom button). If not set, renders default Load More button. */
44
- loadMoreSlot?: ReactNode;
45
- }
46
-
47
- export type PaginationControlsProps =
48
- | PaginationControlsDefaultProps
49
- | PaginationControlsLoadMoreProps;
50
-
51
- function isLoadMoreProps(props: PaginationControlsProps): props is PaginationControlsLoadMoreProps {
52
- return props.variant === "loadMore";
53
- }
54
-
55
- export default function PaginationControls(props: PaginationControlsProps) {
56
- const { pageSize, pageSizeOptions, onPageSizeChange, disabled = false } = props;
57
-
29
+ export default function PaginationControls({
30
+ pageIndex,
31
+ hasNextPage,
32
+ hasPreviousPage,
33
+ pageSize,
34
+ pageSizeOptions,
35
+ onNextPage,
36
+ onPreviousPage,
37
+ onPageSizeChange,
38
+ disabled = false,
39
+ }: PaginationControlsProps) {
58
40
  const handlePageSizeChange = (newValue: string) => {
59
41
  const newSize = parseInt(newValue, 10);
60
42
  if (!isNaN(newSize) && newSize !== pageSize) {
61
43
  onPageSizeChange(newSize);
62
44
  }
63
45
  };
64
-
65
- const pageSizeBlock = (
66
- <div
67
- className="flex justify-center sm:justify-start items-center gap-2 shrink-0"
68
- role="group"
69
- aria-label="Page size selector"
70
- >
71
- <Label htmlFor="page-size-select" className="text-sm font-normal whitespace-nowrap">
72
- Results per page:
73
- </Label>
74
- <Select value={pageSize.toString()} onValueChange={handlePageSizeChange} disabled={disabled}>
75
- <SelectTrigger
76
- id="page-size-select"
77
- className="w-16"
78
- aria-label="Select number of results per page"
79
- >
80
- <SelectValue />
81
- </SelectTrigger>
82
- <SelectContent>
83
- {pageSizeOptions.map((size) => (
84
- <SelectItem key={size} value={size.toString()}>
85
- {size}
86
- </SelectItem>
87
- ))}
88
- </SelectContent>
89
- </Select>
90
- </div>
91
- );
92
-
93
- if (isLoadMoreProps(props)) {
94
- const { hasNextPage, onLoadMore, loadMoreLoading = false, loadMoreSlot } = props;
95
- return (
96
- <div className="w-full grid grid-cols-1 sm:grid-cols-2 items-center justify-center gap-4 py-2">
97
- {pageSizeBlock}
98
- <div className="flex justify-center sm:justify-end">
99
- {loadMoreSlot !== undefined ? (
100
- loadMoreSlot
101
- ) : hasNextPage ? (
102
- <Button
103
- onClick={onLoadMore}
104
- disabled={loadMoreLoading}
105
- aria-label={loadMoreLoading ? "Loading..." : "Load More"}
106
- >
107
- {loadMoreLoading ? "Loading..." : "Load More"}
108
- </Button>
109
- ) : null}
110
- </div>
111
- </div>
112
- );
113
- }
114
-
115
- const { pageIndex, hasNextPage, hasPreviousPage, onNextPage, onPreviousPage } = props;
116
46
  const currentPage = pageIndex + 1;
117
47
  const prevDisabled = disabled || !hasPreviousPage;
118
48
  const nextDisabled = disabled || !hasNextPage;
119
49
 
120
50
  return (
121
51
  <div className="w-full grid grid-cols-1 sm:grid-cols-2 items-center justify-center gap-4 py-2">
122
- {pageSizeBlock}
52
+ <div
53
+ className="flex justify-center sm:justify-start items-center gap-2 shrink-0 row-2 sm:row-1"
54
+ role="group"
55
+ aria-label="Page size selector"
56
+ >
57
+ <Label htmlFor="page-size-select" className="text-sm font-normal whitespace-nowrap">
58
+ Results per page:
59
+ </Label>
60
+ <Select
61
+ value={pageSize.toString()}
62
+ onValueChange={handlePageSizeChange}
63
+ disabled={disabled}
64
+ >
65
+ <SelectTrigger
66
+ id="page-size-select"
67
+ className="w-16"
68
+ aria-label="Select number of results per page"
69
+ >
70
+ <SelectValue />
71
+ </SelectTrigger>
72
+ <SelectContent>
73
+ {pageSizeOptions.map((size) => (
74
+ <SelectItem key={size} value={size.toString()}>
75
+ {size}
76
+ </SelectItem>
77
+ ))}
78
+ </SelectContent>
79
+ </Select>
80
+ </div>
123
81
  <Pagination className="w-full mx-0 sm:justify-end">
124
82
  <PaginationContent>
125
83
  <PaginationItem>
@@ -7,52 +7,31 @@ import {
7
7
  } from "../../../../components/ui/select";
8
8
  import { Label } from "../../../../components/ui/label";
9
9
  import { cn } from "../../../../lib/utils";
10
- import type { FilterFieldConfig, ActiveFilterValue } from "../../utils/filterUtils";
10
+ import { useFilterField } from "../FilterContext";
11
+ import type { ActiveFilterValue } from "../../utils/filterUtils";
11
12
 
12
13
  const ALL_VALUE = "__all__";
13
14
 
14
15
  interface BooleanFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
15
- config: FilterFieldConfig;
16
- value: ActiveFilterValue | undefined;
17
- onChange: (value: ActiveFilterValue | undefined) => void;
18
- labelProps?: React.ComponentProps<typeof Label>;
19
- controlProps?: Omit<
20
- React.ComponentProps<typeof BooleanFilterSelect>,
21
- "config" | "value" | "onChange"
22
- >;
23
- helpTextProps?: React.ComponentProps<"p">;
16
+ field: string;
17
+ label: string;
18
+ helpText?: string;
24
19
  }
25
20
 
26
- export function BooleanFilter({
27
- config,
28
- value,
29
- onChange,
30
- className,
31
- labelProps,
32
- controlProps,
33
- helpTextProps,
34
- ...props
35
- }: BooleanFilterProps) {
21
+ export function BooleanFilter({ field, label, helpText, className, ...props }: BooleanFilterProps) {
22
+ const { value, onChange } = useFilterField(field);
36
23
  return (
37
24
  <div className={cn("space-y-1.5", className)} {...props}>
38
- <Label htmlFor={`filter-${config.field}`} {...labelProps}>
39
- {labelProps?.children ?? config.label}
40
- </Label>
41
- <BooleanFilterSelect config={config} value={value} onChange={onChange} {...controlProps} />
42
- {config.helpText && (
43
- <p
44
- {...helpTextProps}
45
- className={cn("text-xs text-muted-foreground", helpTextProps?.className)}
46
- >
47
- {helpTextProps?.children ?? config.helpText}
48
- </p>
49
- )}
25
+ <Label htmlFor={`filter-${field}`}>{label}</Label>
26
+ <BooleanFilterSelect field={field} label={label} value={value} onChange={onChange} />
27
+ {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
50
28
  </div>
51
29
  );
52
30
  }
53
31
 
54
32
  interface BooleanFilterSelectProps {
55
- config: FilterFieldConfig;
33
+ field: string;
34
+ label: string;
56
35
  value: ActiveFilterValue | undefined;
57
36
  onChange: (value: ActiveFilterValue | undefined) => void;
58
37
  triggerProps?: React.ComponentProps<typeof SelectTrigger>;
@@ -60,7 +39,8 @@ interface BooleanFilterSelectProps {
60
39
  }
61
40
 
62
41
  export function BooleanFilterSelect({
63
- config,
42
+ field,
43
+ label,
64
44
  value,
65
45
  onChange,
66
46
  triggerProps,
@@ -73,12 +53,12 @@ export function BooleanFilterSelect({
73
53
  if (v === ALL_VALUE) {
74
54
  onChange(undefined);
75
55
  } else {
76
- onChange({ field: config.field, label: config.label, type: "boolean", value: v });
56
+ onChange({ field, label, type: "boolean", value: v });
77
57
  }
78
58
  }}
79
59
  >
80
60
  <SelectTrigger
81
- id={`filter-${config.field}`}
61
+ id={`filter-${field}`}
82
62
  {...triggerProps}
83
63
  className={cn("w-full", triggerProps?.className)}
84
64
  >