@salesforce/webapp-template-app-react-sample-b2e-experimental 1.73.0 → 1.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/objects/Maintenance_Request__c/Maintenance_Request__c.object-meta.xml +11 -1
- package/dist/force-app/main/default/objects/Maintenance_Worker__c/Maintenance_Worker__c.object-meta.xml +6 -1
- package/dist/force-app/main/default/objects/Property__c/Property__c.object-meta.xml +6 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/package.json +7 -5
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/api/maintenanceWorkers.ts +60 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ApplicationsTable.tsx +59 -62
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/FiltersFromApi.tsx +200 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ListPageFilters.tsx +97 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/MaintenanceTable.tsx +2 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ObjectSelect.tsx +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/VerticalNav.tsx +6 -4
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/dashboard/GlobalSearchBar.tsx +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/FilterErrorAlert.tsx +15 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/PageErrorState.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/PageLoadingState.tsx +18 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldRange.tsx +40 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldSelect.tsx +190 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldText.tsx +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/ListPageFilterRow.tsx +100 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/layout/PageContainer.tsx +9 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/layout/PageHeader.tsx +21 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/list/ListPageWithFilters.tsx +70 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/constants.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/index.ts +19 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectDetailService.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectInfoGraphQLService.ts +194 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectInfoService.ts +199 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/recordListGraphQLService.ts +364 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailFields.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailForm.tsx +146 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailHeader.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailLayoutSections.tsx +80 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/Section.tsx +108 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/SectionRow.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/UiApiDetailForm.tsx +140 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FieldValueDisplay.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedAddress.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedEmail.tsx +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedPhone.tsx +24 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedText.tsx +11 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedUrl.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/index.ts +6 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterField.tsx +54 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterInput.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterSelect.tsx +72 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FiltersPanel.tsx +380 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/forms/filters-form.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/forms/submit-button.tsx +47 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/GlobalSearchInput.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/ResultCardFields.tsx +71 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchHeader.tsx +31 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchPagination.tsx +144 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchResultCard.tsx +136 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchResultsPanel.tsx +197 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/shared/LoadingFallback.tsx +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/filters/FilterInput.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/filters/FilterSelect.tsx +72 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/form.tsx +209 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/index.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useObjectInfoBatch.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useObjectSearchData.ts +395 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useRecordDetailLayout.ts +156 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useRecordListGraphQL.ts +135 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/pages/DetailPage.tsx +109 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/pages/GlobalSearch.tsx +229 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/filters/filters.ts +121 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/filters/picklist.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/index.ts +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/objectInfo/objectInfo.ts +166 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/recordDetail/recordDetail.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/search/searchResults.ts +229 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/apiUtils.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/cacheUtils.ts +76 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/debounce.ts +89 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/fieldUtils.ts +354 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/fieldValueExtractor.ts +67 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/filterUtils.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/formDataTransformUtils.ts +260 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/formUtils.ts +142 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLNodeFieldUtils.ts +186 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLObjectInfoAdapter.ts +319 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLRecordAdapter.ts +90 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/index.ts +59 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/layoutTransformUtils.ts +236 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/linkUtils.ts +14 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/paginationUtils.ts +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/recordUtils.ts +159 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/sanitizationUtils.ts +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/hooks/useAccumulatedListPages.ts +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/hooks/useListPage.ts +167 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/index.ts +8 -4
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/applicationAdapter.ts +33 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/applicationColumns.ts +28 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/constants.ts +24 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/fieldMappers.ts +71 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/filterUtils.ts +165 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/globalSearchConstants.ts +40 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/listFilters.ts +152 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/listPageConfig.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceAdapter.ts +110 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceColumns.ts +24 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceWorkerAdapter.ts +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceWorkerColumns.ts +25 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/objectApiNames.ts +13 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/propertyAdapter.ts +68 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/propertyColumns.ts +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/routeConfig.ts +35 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/types.ts +10 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Applications.tsx +47 -62
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Home.tsx +130 -98
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Maintenance.tsx +74 -91
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/MaintenanceWorkers.tsx +138 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Properties.tsx +166 -85
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/routes.tsx +41 -2
- package/dist/package.json +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FiltersPanel Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a panel of filter inputs for refining search results.
|
|
5
|
+
* Supports both text inputs and select dropdowns based on filter affordance.
|
|
6
|
+
*
|
|
7
|
+
* @param filters - Array of filter definitions to display
|
|
8
|
+
* @param picklistValues - Record of picklist values keyed by field path
|
|
9
|
+
* @param loading - Whether filters are currently loading
|
|
10
|
+
* @param onApplyFilters - Callback when filters are applied, receives filter values object
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* - Automatically initializes filter values from defaultValues
|
|
14
|
+
* - Shows loading skeleton while filters are being fetched
|
|
15
|
+
* - Supports "Apply Filters" and "Reset" actions
|
|
16
|
+
* - Uses TanStack Form for form state management (similar to Login page)
|
|
17
|
+
* - Uses FiltersForm wrapper for consistent UX/UI (similar to AuthForm pattern)
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <FiltersPanel
|
|
22
|
+
* filters={filters}
|
|
23
|
+
* picklistValues={picklistValues}
|
|
24
|
+
* loading={false}
|
|
25
|
+
* onApplyFilters={(values) => applyFilters(values)}
|
|
26
|
+
* />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
|
30
|
+
import {
|
|
31
|
+
Card,
|
|
32
|
+
CardContent,
|
|
33
|
+
CardHeader,
|
|
34
|
+
CardTitle,
|
|
35
|
+
} from "../../../../components/ui/card";
|
|
36
|
+
import { Skeleton } from "../../../../components/ui/skeleton";
|
|
37
|
+
import { FiltersForm } from "../forms/filters-form";
|
|
38
|
+
import { Field, FieldLabel, FieldDescription } from "../../../../components/ui/field";
|
|
39
|
+
import { useAppForm, validateRangeValues } from "../../hooks/form";
|
|
40
|
+
import type { Filter, FilterCriteria } from "../../types/filters/filters";
|
|
41
|
+
import type { PicklistValue } from "../../types/filters/picklist";
|
|
42
|
+
import { parseFilterValue } from "../../utils/filterUtils";
|
|
43
|
+
import { sanitizeFilterValue } from "../../utils/sanitizationUtils";
|
|
44
|
+
import { getFormValueByPath } from "../../utils/formUtils";
|
|
45
|
+
|
|
46
|
+
interface FiltersPanelProps {
|
|
47
|
+
filters: Filter[];
|
|
48
|
+
picklistValues: Record<string, PicklistValue[]>;
|
|
49
|
+
loading: boolean;
|
|
50
|
+
objectApiName: string;
|
|
51
|
+
onApplyFilters: (filterCriteria: FilterCriteria[]) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function FiltersPanel({
|
|
55
|
+
filters,
|
|
56
|
+
picklistValues,
|
|
57
|
+
loading,
|
|
58
|
+
objectApiName,
|
|
59
|
+
onApplyFilters,
|
|
60
|
+
}: FiltersPanelProps) {
|
|
61
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
62
|
+
const [submitSuccess, setSubmitSuccess] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
const defaultValues = useMemo(() => {
|
|
65
|
+
if (!filters || !Array.isArray(filters)) {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const values: Record<string, string> = {};
|
|
70
|
+
filters.forEach((filter) => {
|
|
71
|
+
if (filter && filter.targetFieldPath) {
|
|
72
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
73
|
+
|
|
74
|
+
if (affordance === "range") {
|
|
75
|
+
const minFieldName = `${filter.targetFieldPath}_min`;
|
|
76
|
+
const maxFieldName = `${filter.targetFieldPath}_max`;
|
|
77
|
+
|
|
78
|
+
if (filter.defaultValues && filter.defaultValues.length >= 2) {
|
|
79
|
+
values[minFieldName] = filter.defaultValues[0] || "";
|
|
80
|
+
values[maxFieldName] = filter.defaultValues[1] || "";
|
|
81
|
+
} else {
|
|
82
|
+
values[minFieldName] = "";
|
|
83
|
+
values[maxFieldName] = "";
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
if (filter.defaultValues && filter.defaultValues.length > 0) {
|
|
87
|
+
values[filter.targetFieldPath] = filter.defaultValues[0];
|
|
88
|
+
} else {
|
|
89
|
+
values[filter.targetFieldPath] = "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return values;
|
|
95
|
+
}, [filters]);
|
|
96
|
+
|
|
97
|
+
const form = useAppForm({
|
|
98
|
+
defaultValues,
|
|
99
|
+
onSubmit: async ({ value }) => {
|
|
100
|
+
setSubmitError(null);
|
|
101
|
+
setSubmitSuccess(null);
|
|
102
|
+
try {
|
|
103
|
+
const filterCriteria: FilterCriteria[] = [];
|
|
104
|
+
|
|
105
|
+
for (const filter of filters) {
|
|
106
|
+
if (!filter || !filter.targetFieldPath) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
111
|
+
|
|
112
|
+
if (affordance === "range") {
|
|
113
|
+
const minFieldName = `${filter.targetFieldPath}_min`;
|
|
114
|
+
const maxFieldName = `${filter.targetFieldPath}_max`;
|
|
115
|
+
const minValueRaw = value[minFieldName] || "";
|
|
116
|
+
const maxValueRaw = value[maxFieldName] || "";
|
|
117
|
+
|
|
118
|
+
const minValue = sanitizeFilterValue(minValueRaw);
|
|
119
|
+
const maxValue = sanitizeFilterValue(maxValueRaw);
|
|
120
|
+
|
|
121
|
+
if (minValue && maxValue) {
|
|
122
|
+
const rangeError = validateRangeValues(minValue, maxValue);
|
|
123
|
+
if (rangeError) {
|
|
124
|
+
setSubmitError(rangeError);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (minValue) {
|
|
130
|
+
const parsedMin = parseFilterValue(minValue);
|
|
131
|
+
if (parsedMin !== "") {
|
|
132
|
+
filterCriteria.push({
|
|
133
|
+
objectApiName,
|
|
134
|
+
fieldPath: filter.targetFieldPath,
|
|
135
|
+
operator: "gte",
|
|
136
|
+
values: [parsedMin],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (maxValue) {
|
|
142
|
+
const parsedMax = parseFilterValue(maxValue);
|
|
143
|
+
if (parsedMax !== "") {
|
|
144
|
+
filterCriteria.push({
|
|
145
|
+
objectApiName,
|
|
146
|
+
fieldPath: filter.targetFieldPath,
|
|
147
|
+
operator: "lte",
|
|
148
|
+
values: [parsedMax],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
const fieldValueRaw =
|
|
154
|
+
getFormValueByPath(value as Record<string, unknown>, filter.targetFieldPath) || "";
|
|
155
|
+
const fieldValue = sanitizeFilterValue(fieldValueRaw);
|
|
156
|
+
|
|
157
|
+
if (fieldValue) {
|
|
158
|
+
if (affordance === "select") {
|
|
159
|
+
filterCriteria.push({
|
|
160
|
+
objectApiName,
|
|
161
|
+
fieldPath: filter.targetFieldPath,
|
|
162
|
+
operator: "eq",
|
|
163
|
+
values: [fieldValue],
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
const likeValue = `%${fieldValue}%`;
|
|
167
|
+
filterCriteria.push({
|
|
168
|
+
objectApiName,
|
|
169
|
+
fieldPath: filter.targetFieldPath,
|
|
170
|
+
operator: "like",
|
|
171
|
+
values: [likeValue],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (filterCriteria.length === 0) {
|
|
179
|
+
setSubmitSuccess("No filters applied. Showing all results.");
|
|
180
|
+
} else {
|
|
181
|
+
setSubmitSuccess("Filters applied successfully");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
onApplyFilters(filterCriteria);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to apply filters";
|
|
187
|
+
setSubmitError(errorMessage);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
onSubmitInvalid: () => {},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const previousDefaultValuesRef = useRef<Record<string, string>>({});
|
|
194
|
+
const previousLoadingRef = useRef<boolean>(true);
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const loadingJustCompleted = previousLoadingRef.current && !loading;
|
|
198
|
+
const defaultValuesChanged =
|
|
199
|
+
JSON.stringify(previousDefaultValuesRef.current) !== JSON.stringify(defaultValues);
|
|
200
|
+
|
|
201
|
+
if (loadingJustCompleted && defaultValues && Object.keys(defaultValues).length > 0) {
|
|
202
|
+
form.reset(defaultValues);
|
|
203
|
+
previousDefaultValuesRef.current = defaultValues;
|
|
204
|
+
} else if (defaultValuesChanged && !loading && Object.keys(defaultValues).length > 0) {
|
|
205
|
+
form.reset(defaultValues);
|
|
206
|
+
previousDefaultValuesRef.current = defaultValues;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
previousLoadingRef.current = loading;
|
|
210
|
+
}, [loading, defaultValues]);
|
|
211
|
+
|
|
212
|
+
const handleSuccessDismiss = useCallback(() => {
|
|
213
|
+
setSubmitSuccess(null);
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
const handleReset = useCallback(() => {
|
|
217
|
+
if (!filters || !Array.isArray(filters)) {
|
|
218
|
+
form.reset();
|
|
219
|
+
onApplyFilters([]);
|
|
220
|
+
setSubmitError(null);
|
|
221
|
+
setSubmitSuccess(null);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const resetValues: Record<string, string> = {};
|
|
226
|
+
filters.forEach((filter) => {
|
|
227
|
+
if (filter && filter.targetFieldPath) {
|
|
228
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
229
|
+
|
|
230
|
+
if (affordance === "range") {
|
|
231
|
+
resetValues[`${filter.targetFieldPath}_min`] = "";
|
|
232
|
+
resetValues[`${filter.targetFieldPath}_max`] = "";
|
|
233
|
+
} else {
|
|
234
|
+
resetValues[filter.targetFieldPath] = "";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
form.reset(resetValues);
|
|
239
|
+
onApplyFilters([]);
|
|
240
|
+
setSubmitError(null);
|
|
241
|
+
setSubmitSuccess(null);
|
|
242
|
+
}, [filters, onApplyFilters, form]);
|
|
243
|
+
|
|
244
|
+
if (loading) {
|
|
245
|
+
return (
|
|
246
|
+
<Card className="w-full" role="region" aria-label="Filters panel">
|
|
247
|
+
<CardHeader>
|
|
248
|
+
<CardTitle>Filters</CardTitle>
|
|
249
|
+
</CardHeader>
|
|
250
|
+
<CardContent
|
|
251
|
+
className="space-y-4"
|
|
252
|
+
role="status"
|
|
253
|
+
aria-live="polite"
|
|
254
|
+
aria-label="Loading filters"
|
|
255
|
+
>
|
|
256
|
+
<span className="sr-only">Loading filters</span>
|
|
257
|
+
{[1, 2, 3].map((i) => (
|
|
258
|
+
<div key={i} className="space-y-2" aria-hidden="true">
|
|
259
|
+
<Skeleton className="h-4 w-24" />
|
|
260
|
+
<Skeleton className="h-9 w-full" />
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</CardContent>
|
|
264
|
+
</Card>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!filters || !Array.isArray(filters) || filters.length === 0) {
|
|
269
|
+
return (
|
|
270
|
+
<Card className="w-full" role="region" aria-label="Filters panel">
|
|
271
|
+
<CardHeader>
|
|
272
|
+
<CardTitle>Filters</CardTitle>
|
|
273
|
+
</CardHeader>
|
|
274
|
+
<CardContent>
|
|
275
|
+
<p className="text-sm text-muted-foreground">No filters available</p>
|
|
276
|
+
</CardContent>
|
|
277
|
+
</Card>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<form.AppForm>
|
|
283
|
+
<FiltersForm
|
|
284
|
+
title="Filters"
|
|
285
|
+
description="Refine your search results by applying filters"
|
|
286
|
+
error={submitError}
|
|
287
|
+
success={submitSuccess}
|
|
288
|
+
onSuccessDismiss={handleSuccessDismiss}
|
|
289
|
+
submit={{
|
|
290
|
+
text: "Apply Filters",
|
|
291
|
+
loadingText: "Applying filters…",
|
|
292
|
+
}}
|
|
293
|
+
reset={{
|
|
294
|
+
text: "Reset",
|
|
295
|
+
onReset: handleReset,
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
{filters.map((filter) => {
|
|
299
|
+
if (!filter || !filter.targetFieldPath) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const fieldPicklistValues = picklistValues[filter.targetFieldPath] || [];
|
|
304
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
305
|
+
|
|
306
|
+
if (affordance === "range") {
|
|
307
|
+
const minFieldName = `${filter.targetFieldPath}_min`;
|
|
308
|
+
const maxFieldName = `${filter.targetFieldPath}_max`;
|
|
309
|
+
const inputType = "text";
|
|
310
|
+
const placeholder =
|
|
311
|
+
filter.attributes?.placeholder === "null"
|
|
312
|
+
? undefined
|
|
313
|
+
: filter.attributes?.placeholder;
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<Field key={filter.targetFieldPath}>
|
|
317
|
+
<FieldLabel>{filter.label || filter.targetFieldPath}</FieldLabel>
|
|
318
|
+
{filter.helpMessage && <FieldDescription>{filter.helpMessage}</FieldDescription>}
|
|
319
|
+
<div
|
|
320
|
+
className="grid grid-cols-2 gap-3"
|
|
321
|
+
role="group"
|
|
322
|
+
aria-label={`${filter.label || filter.targetFieldPath} range filter`}
|
|
323
|
+
>
|
|
324
|
+
<form.AppField name={minFieldName}>
|
|
325
|
+
{(field) => (
|
|
326
|
+
<field.FilterRangeMinField
|
|
327
|
+
placeholder={placeholder || "Min"}
|
|
328
|
+
type={inputType}
|
|
329
|
+
aria-label={`${filter.label || filter.targetFieldPath} - Minimum`}
|
|
330
|
+
/>
|
|
331
|
+
)}
|
|
332
|
+
</form.AppField>
|
|
333
|
+
<form.AppField name={maxFieldName}>
|
|
334
|
+
{(field) => (
|
|
335
|
+
<field.FilterRangeMaxField
|
|
336
|
+
placeholder={placeholder || "Max"}
|
|
337
|
+
type={inputType}
|
|
338
|
+
aria-label={`${filter.label || filter.targetFieldPath} - Maximum`}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</form.AppField>
|
|
342
|
+
</div>
|
|
343
|
+
</Field>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (affordance === "select") {
|
|
348
|
+
return (
|
|
349
|
+
<form.AppField key={filter.targetFieldPath} name={filter.targetFieldPath}>
|
|
350
|
+
{(field) => (
|
|
351
|
+
<field.FilterSelectField
|
|
352
|
+
label={filter.label || filter.targetFieldPath}
|
|
353
|
+
description={filter.helpMessage || undefined}
|
|
354
|
+
placeholder={filter.attributes?.placeholder || "Select..."}
|
|
355
|
+
options={fieldPicklistValues}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
</form.AppField>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<form.AppField key={filter.targetFieldPath} name={filter.targetFieldPath}>
|
|
364
|
+
{(field) => (
|
|
365
|
+
<field.FilterTextField
|
|
366
|
+
label={filter.label || filter.targetFieldPath}
|
|
367
|
+
description={filter.helpMessage || undefined}
|
|
368
|
+
placeholder={
|
|
369
|
+
filter.attributes?.placeholder ||
|
|
370
|
+
`Enter ${(filter.label || filter.targetFieldPath).toLowerCase()}`
|
|
371
|
+
}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
374
|
+
</form.AppField>
|
|
375
|
+
);
|
|
376
|
+
})}
|
|
377
|
+
</FiltersForm>
|
|
378
|
+
</form.AppForm>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { FieldGroup } from "../../../../components/ui/field";
|
|
2
|
+
import { StatusAlert } from "../../../../components/alerts/status-alert";
|
|
3
|
+
import { CardLayout } from "../../../../components/layouts/card-layout";
|
|
4
|
+
import { SubmitButton } from "./submit-button";
|
|
5
|
+
import { Button } from "../../../../components/ui/button";
|
|
6
|
+
import { useFormContext } from "../../hooks/form";
|
|
7
|
+
import { useId, useEffect, useRef } from "react";
|
|
8
|
+
|
|
9
|
+
const SUCCESS_AUTO_DISMISS_DELAY = 3000;
|
|
10
|
+
|
|
11
|
+
interface FiltersFormProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
|
|
12
|
+
title: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
error?: React.ReactNode;
|
|
15
|
+
success?: React.ReactNode;
|
|
16
|
+
submit: {
|
|
17
|
+
text: string;
|
|
18
|
+
loadingText?: string;
|
|
19
|
+
};
|
|
20
|
+
reset?: {
|
|
21
|
+
text: string;
|
|
22
|
+
onReset: () => void;
|
|
23
|
+
};
|
|
24
|
+
onSuccessDismiss?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wrapper component that provides consistent layout and error/success alert positioning
|
|
29
|
+
* for all filter forms.
|
|
30
|
+
*/
|
|
31
|
+
export function FiltersForm({
|
|
32
|
+
id: providedId,
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
error,
|
|
36
|
+
success,
|
|
37
|
+
children,
|
|
38
|
+
submit,
|
|
39
|
+
reset,
|
|
40
|
+
onSuccessDismiss,
|
|
41
|
+
...props
|
|
42
|
+
}: FiltersFormProps) {
|
|
43
|
+
const form = useFormContext();
|
|
44
|
+
const generatedId = useId();
|
|
45
|
+
const id = providedId ?? generatedId;
|
|
46
|
+
|
|
47
|
+
const isSubmittingSelector = (state: { isSubmitting: boolean }) => state.isSubmitting;
|
|
48
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (timeoutRef.current) {
|
|
52
|
+
clearTimeout(timeoutRef.current);
|
|
53
|
+
timeoutRef.current = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (success && onSuccessDismiss) {
|
|
57
|
+
timeoutRef.current = setTimeout(() => {
|
|
58
|
+
onSuccessDismiss();
|
|
59
|
+
timeoutRef.current = null;
|
|
60
|
+
}, SUCCESS_AUTO_DISMISS_DELAY);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
if (timeoutRef.current) {
|
|
65
|
+
clearTimeout(timeoutRef.current);
|
|
66
|
+
timeoutRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, [success, onSuccessDismiss]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<CardLayout title={title} description={description}>
|
|
73
|
+
<div className="space-y-6">
|
|
74
|
+
{error && <StatusAlert variant="error">{error}</StatusAlert>}
|
|
75
|
+
{success && <StatusAlert variant="success">{success}</StatusAlert>}
|
|
76
|
+
|
|
77
|
+
<form
|
|
78
|
+
id={id}
|
|
79
|
+
onSubmit={(e) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
form.handleSubmit();
|
|
83
|
+
}}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
<FieldGroup>{children}</FieldGroup>
|
|
87
|
+
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
|
88
|
+
<SubmitButton
|
|
89
|
+
form={id}
|
|
90
|
+
label={submit.text}
|
|
91
|
+
loadingLabel={submit.loadingText}
|
|
92
|
+
className="flex-1"
|
|
93
|
+
/>
|
|
94
|
+
{reset && (
|
|
95
|
+
<form.Subscribe selector={isSubmittingSelector}>
|
|
96
|
+
{(isSubmitting: boolean) => (
|
|
97
|
+
<Button
|
|
98
|
+
type="button"
|
|
99
|
+
variant="outline"
|
|
100
|
+
onClick={reset.onReset}
|
|
101
|
+
className="flex-1"
|
|
102
|
+
disabled={isSubmitting}
|
|
103
|
+
>
|
|
104
|
+
{reset.text}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</form.Subscribe>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</form>
|
|
111
|
+
</div>
|
|
112
|
+
</CardLayout>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Button } from "../../../../components/ui/button";
|
|
2
|
+
import { Spinner } from "../../../../components/ui/spinner";
|
|
3
|
+
import { cn } from "../../../../lib/utils";
|
|
4
|
+
import { useFormContext } from "../../hooks/form";
|
|
5
|
+
|
|
6
|
+
interface SubmitButtonProps extends Omit<React.ComponentProps<typeof Button>, "type" | "disabled"> {
|
|
7
|
+
/** Button text when not submitting */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Button text while submitting */
|
|
10
|
+
loadingLabel?: string;
|
|
11
|
+
/** Form id to associate with (for buttons outside form element) */
|
|
12
|
+
form?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isSubmittingSelector = (state: { isSubmitting: boolean }) => state.isSubmitting;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Submit button that subscribes to form submission state.
|
|
19
|
+
* Disables interaction during submission and provides visual feedback.
|
|
20
|
+
*/
|
|
21
|
+
export function SubmitButton({
|
|
22
|
+
label,
|
|
23
|
+
loadingLabel = "Applying…",
|
|
24
|
+
className,
|
|
25
|
+
form: formId,
|
|
26
|
+
...props
|
|
27
|
+
}: SubmitButtonProps) {
|
|
28
|
+
const form = useFormContext();
|
|
29
|
+
return (
|
|
30
|
+
<form.Subscribe selector={isSubmittingSelector}>
|
|
31
|
+
{(isSubmitting: boolean) => (
|
|
32
|
+
<Button
|
|
33
|
+
type="submit"
|
|
34
|
+
form={formId}
|
|
35
|
+
className={cn("w-full", className)}
|
|
36
|
+
disabled={isSubmitting}
|
|
37
|
+
aria-label={isSubmitting ? loadingLabel : label}
|
|
38
|
+
aria-busy={isSubmitting}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{isSubmitting && <Spinner className="mr-2" aria-hidden="true" />}
|
|
42
|
+
<span aria-live="polite">{isSubmitting ? loadingLabel : label}</span>
|
|
43
|
+
</Button>
|
|
44
|
+
)}
|
|
45
|
+
</form.Subscribe>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlobalSearchInput Component
|
|
3
|
+
*
|
|
4
|
+
* Search input with two actions: Search (navigate to results for query) and
|
|
5
|
+
* Browse All (navigate to same results UI with all records for the object).
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useCallback, useMemo, useId } from "react";
|
|
8
|
+
import type { KeyboardEvent, ChangeEvent } from "react";
|
|
9
|
+
import { useNavigate } from "react-router";
|
|
10
|
+
import { Card, CardContent } from "../../../../components/ui/card";
|
|
11
|
+
import { Input } from "../../../../components/ui/input";
|
|
12
|
+
import { Button } from "../../../../components/ui/button";
|
|
13
|
+
import { Search } from "lucide-react";
|
|
14
|
+
import { OBJECT_API_NAMES } from "../../../../constants";
|
|
15
|
+
import { useObjectInfoBatch } from "../../hooks/useObjectInfoBatch";
|
|
16
|
+
|
|
17
|
+
const BROWSE_SEGMENT = "browse__all";
|
|
18
|
+
|
|
19
|
+
const FALLBACK_LABEL_PLURAL = "records";
|
|
20
|
+
|
|
21
|
+
export function GlobalSearchInput() {
|
|
22
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const inputId = useId();
|
|
25
|
+
const searchButtonId = useId();
|
|
26
|
+
const browseButtonId = useId();
|
|
27
|
+
const inputDescriptionId = `${inputId}-description`;
|
|
28
|
+
const { objectInfos } = useObjectInfoBatch([...OBJECT_API_NAMES]);
|
|
29
|
+
const labelPlural = (objectInfos[0]?.labelPlural as string | undefined) ?? FALLBACK_LABEL_PLURAL;
|
|
30
|
+
|
|
31
|
+
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
32
|
+
setSearchQuery(e.target.value);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const handleSearch = useCallback(() => {
|
|
36
|
+
const trimmed = searchQuery.trim();
|
|
37
|
+
if (trimmed) {
|
|
38
|
+
navigate(`/global-search/${encodeURIComponent(trimmed)}`);
|
|
39
|
+
}
|
|
40
|
+
}, [searchQuery, navigate]);
|
|
41
|
+
|
|
42
|
+
const handleBrowseAll = useCallback(() => {
|
|
43
|
+
navigate(`/global-search/${BROWSE_SEGMENT}`);
|
|
44
|
+
}, [navigate]);
|
|
45
|
+
|
|
46
|
+
const handleKeyDown = useCallback(
|
|
47
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
48
|
+
if (e.key === "Enter") {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
handleSearch();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[handleSearch],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const isSearchDisabled = useMemo(() => !searchQuery.trim(), [searchQuery]);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="w-full max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
60
|
+
<Card className="w-full">
|
|
61
|
+
<CardContent className="pt-6">
|
|
62
|
+
<div className="flex flex-col sm:flex-row gap-3">
|
|
63
|
+
<div className="flex-1 relative">
|
|
64
|
+
<Search
|
|
65
|
+
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-600"
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
/>
|
|
68
|
+
<Input
|
|
69
|
+
id={inputId}
|
|
70
|
+
type="search"
|
|
71
|
+
placeholder={`Search for ${labelPlural}`}
|
|
72
|
+
value={searchQuery}
|
|
73
|
+
onChange={handleInputChange}
|
|
74
|
+
onKeyDown={handleKeyDown}
|
|
75
|
+
className="pl-10"
|
|
76
|
+
aria-label={`Search for ${labelPlural}`}
|
|
77
|
+
aria-describedby={inputDescriptionId}
|
|
78
|
+
/>
|
|
79
|
+
<p id={inputDescriptionId} className="sr-only">
|
|
80
|
+
Enter your search query and press Enter or click Search. Or click Browse All to see
|
|
81
|
+
all {labelPlural}.
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
|
86
|
+
<Button
|
|
87
|
+
id={searchButtonId}
|
|
88
|
+
onClick={handleSearch}
|
|
89
|
+
disabled={isSearchDisabled}
|
|
90
|
+
className="w-full sm:w-auto"
|
|
91
|
+
aria-label="Search"
|
|
92
|
+
aria-describedby={inputDescriptionId}
|
|
93
|
+
variant="default"
|
|
94
|
+
>
|
|
95
|
+
<Search className="h-4 w-4 mr-2" aria-hidden="true" />
|
|
96
|
+
Search
|
|
97
|
+
</Button>
|
|
98
|
+
<Button
|
|
99
|
+
id={browseButtonId}
|
|
100
|
+
variant="outline"
|
|
101
|
+
onClick={handleBrowseAll}
|
|
102
|
+
className="w-full sm:w-auto"
|
|
103
|
+
aria-label={`Browse all ${labelPlural}`}
|
|
104
|
+
aria-describedby={inputDescriptionId}
|
|
105
|
+
>
|
|
106
|
+
Browse All {labelPlural}
|
|
107
|
+
</Button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|