@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.
- package/dist/.a4drules/webapp-data.md +5 -5
- package/dist/.a4drules/webapp-ui.md +2 -2
- package/dist/AGENT.md +6 -10
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package.json +3 -3
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/graphqlClient.ts +25 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/__examples__/pages/AccountSearch.tsx +82 -54
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/FilterContext.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/PaginationControls.tsx +45 -87
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/BooleanFilter.tsx +16 -36
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/DateFilter.tsx +33 -77
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/DateRangeFilter.tsx +14 -23
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/MultiSelectFilter.tsx +18 -26
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/NumericRangeFilter.tsx +22 -39
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/SearchFilter.tsx +12 -15
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/SelectFilter.tsx +30 -34
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/filters/TextFilter.tsx +27 -30
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useAsyncData.ts +1 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useCachedAsyncData.ts +1 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/hooks/useObjectSearchParams.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/routes.tsx +0 -17
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/dist/{.a4drules/skills/using-salesforce-data → scripts}/graphql-search.sh +4 -4
- package/package.json +1 -1
- package/dist/.a4drules/skills/building-data-visualization/SKILL.md +0 -72
- package/dist/.a4drules/skills/building-data-visualization/implementation/bar-line-chart.md +0 -316
- package/dist/.a4drules/skills/building-data-visualization/implementation/dashboard-layout.md +0 -189
- package/dist/.a4drules/skills/building-data-visualization/implementation/donut-chart.md +0 -181
- package/dist/.a4drules/skills/building-data-visualization/implementation/stat-card.md +0 -150
- package/dist/.a4drules/skills/building-react-components/SKILL.md +0 -96
- package/dist/.a4drules/skills/building-react-components/implementation/component.md +0 -78
- package/dist/.a4drules/skills/building-react-components/implementation/header-footer.md +0 -132
- package/dist/.a4drules/skills/building-react-components/implementation/page.md +0 -93
- package/dist/.a4drules/skills/configuring-csp-trusted-sites/SKILL.md +0 -90
- package/dist/.a4drules/skills/configuring-csp-trusted-sites/implementation/metadata-format.md +0 -281
- package/dist/.a4drules/skills/configuring-webapp-metadata/SKILL.md +0 -158
- package/dist/.a4drules/skills/creating-webapp/SKILL.md +0 -140
- package/dist/.a4drules/skills/deploying-to-salesforce/SKILL.md +0 -226
- package/dist/.a4drules/skills/implementing-file-upload/SKILL.md +0 -396
- package/dist/.a4drules/skills/installing-webapp-features/SKILL.md +0 -210
- package/dist/.a4drules/skills/managing-agentforce-conversation-client/SKILL.md +0 -186
- package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/constraints.md +0 -134
- package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/examples.md +0 -132
- package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/style-tokens.md +0 -101
- package/dist/.a4drules/skills/managing-agentforce-conversation-client/references/troubleshooting.md +0 -57
- package/dist/.a4drules/skills/using-salesforce-data/SKILL.md +0 -363
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/object-search/components/FilterPanel.tsx +0 -127
- 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
|
|
77
|
+
bash scripts/graphql-search.sh Account
|
|
78
78
|
|
|
79
79
|
# Multiple entities at once
|
|
80
|
-
bash
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
|
57
|
+
## Agent rules (.a4drules/)
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Markdown rules at the project root under **.a4drules/** define platform constraints:
|
|
60
60
|
|
|
61
|
-
-
|
|
62
|
-
-
|
|
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.
|
|
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)**:
|
|
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.
|
|
19
|
-
"@salesforce/webapp-experimental": "^1.112.
|
|
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.
|
|
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",
|
package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/graphqlClient.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
>(
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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-${
|
|
39
|
-
|
|
40
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
+
onChange({ field, label, type: "boolean", value: v });
|
|
77
57
|
}
|
|
78
58
|
}}
|
|
79
59
|
>
|
|
80
60
|
<SelectTrigger
|
|
81
|
-
id={`filter-${
|
|
61
|
+
id={`filter-${field}`}
|
|
82
62
|
{...triggerProps}
|
|
83
63
|
className={cn("w-full", triggerProps?.className)}
|
|
84
64
|
>
|