@salesforce/webapp-template-app-react-template-b2e-experimental 1.59.2 → 1.60.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 +8 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package-lock.json +4255 -227
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package.json +11 -2
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/index.ts +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/objectDetailService.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/objectInfoGraphQLService.ts +194 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/objectInfoService.ts +199 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/recordListGraphQLService.ts +365 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/FiltersPanel.tsx +375 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/LoadingFallback.tsx +61 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/SearchResultCard.tsx +131 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/alerts/status-alert.tsx +45 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailFields.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailForm.tsx +146 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailHeader.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailLayoutSections.tsx +80 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/Section.tsx +108 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/SectionRow.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/UiApiDetailForm.tsx +140 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedAddress.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedEmail.tsx +17 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedPhone.tsx +24 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedText.tsx +11 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedUrl.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/index.ts +6 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/filters/FilterField.tsx +54 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/filters/FilterInput.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/filters/FilterSelect.tsx +72 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/forms/filters-form.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/forms/submit-button.tsx +47 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/layout/card-layout.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/ResultCardFields.tsx +71 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/SearchHeader.tsx +31 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/SearchPagination.tsx +144 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/SearchResultsPanel.tsx +197 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/shared/GlobalSearchInput.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/alert.tsx +69 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/button.tsx +67 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/card.tsx +92 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/dialog.tsx +143 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/field.tsx +222 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/index.ts +84 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/input.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/label.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/pagination.tsx +112 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/select.tsx +183 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/separator.tsx +26 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/skeleton.tsx +14 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/spinner.tsx +15 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/table.tsx +87 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/tabs.tsx +78 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components.json +18 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/constants.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/index.ts +33 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/form.tsx +208 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/index.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useObjectInfoBatch.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useObjectSearchData.ts +380 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useRecordDetailLayout.ts +156 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useRecordListGraphQL.ts +135 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/lib/utils.ts +6 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/DetailPage.tsx +109 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/GlobalSearch.tsx +229 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/Home.tsx +11 -10
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/routes.tsx +22 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/styles/global.css +122 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/filters/filters.ts +120 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/filters/picklist.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/index.ts +4 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/objectInfo/objectInfo.ts +166 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/recordDetail/recordDetail.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/search/searchResults.ts +229 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/apiUtils.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/cacheUtils.ts +76 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/debounce.ts +89 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/fieldUtils.ts +354 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/fieldValueExtractor.ts +67 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/filterUtils.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/formDataTransformUtils.ts +260 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/formUtils.ts +142 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/graphQLNodeFieldUtils.ts +186 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/graphQLObjectInfoAdapter.ts +319 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/graphQLRecordAdapter.ts +90 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/index.ts +59 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/layoutTransformUtils.ts +236 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/linkUtils.ts +14 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/paginationUtils.ts +49 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/recordUtils.ts +159 -0
- package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/sanitizationUtils.ts +49 -0
- package/dist/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,375 @@
|
|
|
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 { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
|
31
|
+
import { Skeleton } from "./ui/skeleton";
|
|
32
|
+
import { FiltersForm } from "./forms/filters-form";
|
|
33
|
+
import { Field, FieldLabel, FieldDescription } from "./ui/field";
|
|
34
|
+
import { useAppForm, validateRangeValues } from "../hooks/form";
|
|
35
|
+
import type { Filter, FilterCriteria } from "../types/filters/filters";
|
|
36
|
+
import type { PicklistValue } from "../types/filters/picklist";
|
|
37
|
+
import { parseFilterValue } from "../utils/filterUtils";
|
|
38
|
+
import { sanitizeFilterValue } from "../utils/sanitizationUtils";
|
|
39
|
+
import { getFormValueByPath } from "../utils/formUtils";
|
|
40
|
+
|
|
41
|
+
interface FiltersPanelProps {
|
|
42
|
+
filters: Filter[];
|
|
43
|
+
picklistValues: Record<string, PicklistValue[]>;
|
|
44
|
+
loading: boolean;
|
|
45
|
+
objectApiName: string;
|
|
46
|
+
onApplyFilters: (filterCriteria: FilterCriteria[]) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function FiltersPanel({
|
|
50
|
+
filters,
|
|
51
|
+
picklistValues,
|
|
52
|
+
loading,
|
|
53
|
+
objectApiName,
|
|
54
|
+
onApplyFilters,
|
|
55
|
+
}: FiltersPanelProps) {
|
|
56
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
57
|
+
const [submitSuccess, setSubmitSuccess] = useState<string | null>(null);
|
|
58
|
+
|
|
59
|
+
const defaultValues = useMemo(() => {
|
|
60
|
+
if (!filters || !Array.isArray(filters)) {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const values: Record<string, string> = {};
|
|
65
|
+
filters.forEach((filter) => {
|
|
66
|
+
if (filter && filter.targetFieldPath) {
|
|
67
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
68
|
+
|
|
69
|
+
if (affordance === "range") {
|
|
70
|
+
const minFieldName = `${filter.targetFieldPath}_min`;
|
|
71
|
+
const maxFieldName = `${filter.targetFieldPath}_max`;
|
|
72
|
+
|
|
73
|
+
if (filter.defaultValues && filter.defaultValues.length >= 2) {
|
|
74
|
+
values[minFieldName] = filter.defaultValues[0] || "";
|
|
75
|
+
values[maxFieldName] = filter.defaultValues[1] || "";
|
|
76
|
+
} else {
|
|
77
|
+
values[minFieldName] = "";
|
|
78
|
+
values[maxFieldName] = "";
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
if (filter.defaultValues && filter.defaultValues.length > 0) {
|
|
82
|
+
values[filter.targetFieldPath] = filter.defaultValues[0];
|
|
83
|
+
} else {
|
|
84
|
+
values[filter.targetFieldPath] = "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return values;
|
|
90
|
+
}, [filters]);
|
|
91
|
+
|
|
92
|
+
const form = useAppForm({
|
|
93
|
+
defaultValues,
|
|
94
|
+
onSubmit: async ({ value }) => {
|
|
95
|
+
setSubmitError(null);
|
|
96
|
+
setSubmitSuccess(null);
|
|
97
|
+
try {
|
|
98
|
+
const filterCriteria: FilterCriteria[] = [];
|
|
99
|
+
|
|
100
|
+
for (const filter of filters) {
|
|
101
|
+
if (!filter || !filter.targetFieldPath) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
106
|
+
|
|
107
|
+
if (affordance === "range") {
|
|
108
|
+
const minFieldName = `${filter.targetFieldPath}_min`;
|
|
109
|
+
const maxFieldName = `${filter.targetFieldPath}_max`;
|
|
110
|
+
const minValueRaw = value[minFieldName] || "";
|
|
111
|
+
const maxValueRaw = value[maxFieldName] || "";
|
|
112
|
+
|
|
113
|
+
const minValue = sanitizeFilterValue(minValueRaw);
|
|
114
|
+
const maxValue = sanitizeFilterValue(maxValueRaw);
|
|
115
|
+
|
|
116
|
+
if (minValue && maxValue) {
|
|
117
|
+
const rangeError = validateRangeValues(minValue, maxValue);
|
|
118
|
+
if (rangeError) {
|
|
119
|
+
setSubmitError(rangeError);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (minValue) {
|
|
125
|
+
const parsedMin = parseFilterValue(minValue);
|
|
126
|
+
if (parsedMin !== "") {
|
|
127
|
+
filterCriteria.push({
|
|
128
|
+
objectApiName,
|
|
129
|
+
fieldPath: filter.targetFieldPath,
|
|
130
|
+
operator: "gte",
|
|
131
|
+
values: [parsedMin],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (maxValue) {
|
|
137
|
+
const parsedMax = parseFilterValue(maxValue);
|
|
138
|
+
if (parsedMax !== "") {
|
|
139
|
+
filterCriteria.push({
|
|
140
|
+
objectApiName,
|
|
141
|
+
fieldPath: filter.targetFieldPath,
|
|
142
|
+
operator: "lte",
|
|
143
|
+
values: [parsedMax],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const fieldValueRaw =
|
|
149
|
+
getFormValueByPath(value as Record<string, unknown>, filter.targetFieldPath) || "";
|
|
150
|
+
const fieldValue = sanitizeFilterValue(fieldValueRaw);
|
|
151
|
+
|
|
152
|
+
if (fieldValue) {
|
|
153
|
+
if (affordance === "select") {
|
|
154
|
+
filterCriteria.push({
|
|
155
|
+
objectApiName,
|
|
156
|
+
fieldPath: filter.targetFieldPath,
|
|
157
|
+
operator: "eq",
|
|
158
|
+
values: [fieldValue],
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
const likeValue = `%${fieldValue}%`;
|
|
162
|
+
filterCriteria.push({
|
|
163
|
+
objectApiName,
|
|
164
|
+
fieldPath: filter.targetFieldPath,
|
|
165
|
+
operator: "like",
|
|
166
|
+
values: [likeValue],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (filterCriteria.length === 0) {
|
|
174
|
+
setSubmitSuccess("No filters applied. Showing all results.");
|
|
175
|
+
} else {
|
|
176
|
+
setSubmitSuccess("Filters applied successfully");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onApplyFilters(filterCriteria);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to apply filters";
|
|
182
|
+
setSubmitError(errorMessage);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
onSubmitInvalid: () => {},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const previousDefaultValuesRef = useRef<Record<string, string>>({});
|
|
189
|
+
const previousLoadingRef = useRef<boolean>(true);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
const loadingJustCompleted = previousLoadingRef.current && !loading;
|
|
193
|
+
const defaultValuesChanged =
|
|
194
|
+
JSON.stringify(previousDefaultValuesRef.current) !== JSON.stringify(defaultValues);
|
|
195
|
+
|
|
196
|
+
if (loadingJustCompleted && defaultValues && Object.keys(defaultValues).length > 0) {
|
|
197
|
+
form.reset(defaultValues);
|
|
198
|
+
previousDefaultValuesRef.current = defaultValues;
|
|
199
|
+
} else if (defaultValuesChanged && !loading && Object.keys(defaultValues).length > 0) {
|
|
200
|
+
form.reset(defaultValues);
|
|
201
|
+
previousDefaultValuesRef.current = defaultValues;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
previousLoadingRef.current = loading;
|
|
205
|
+
}, [loading, defaultValues]);
|
|
206
|
+
|
|
207
|
+
const handleSuccessDismiss = useCallback(() => {
|
|
208
|
+
setSubmitSuccess(null);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
const handleReset = useCallback(() => {
|
|
212
|
+
if (!filters || !Array.isArray(filters)) {
|
|
213
|
+
form.reset();
|
|
214
|
+
onApplyFilters([]);
|
|
215
|
+
setSubmitError(null);
|
|
216
|
+
setSubmitSuccess(null);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const resetValues: Record<string, string> = {};
|
|
221
|
+
filters.forEach((filter) => {
|
|
222
|
+
if (filter && filter.targetFieldPath) {
|
|
223
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
224
|
+
|
|
225
|
+
if (affordance === "range") {
|
|
226
|
+
resetValues[`${filter.targetFieldPath}_min`] = "";
|
|
227
|
+
resetValues[`${filter.targetFieldPath}_max`] = "";
|
|
228
|
+
} else {
|
|
229
|
+
resetValues[filter.targetFieldPath] = "";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
form.reset(resetValues);
|
|
234
|
+
onApplyFilters([]);
|
|
235
|
+
setSubmitError(null);
|
|
236
|
+
setSubmitSuccess(null);
|
|
237
|
+
}, [filters, onApplyFilters, form]);
|
|
238
|
+
|
|
239
|
+
if (loading) {
|
|
240
|
+
return (
|
|
241
|
+
<Card className="w-full" role="region" aria-label="Filters panel">
|
|
242
|
+
<CardHeader>
|
|
243
|
+
<CardTitle>Filters</CardTitle>
|
|
244
|
+
</CardHeader>
|
|
245
|
+
<CardContent
|
|
246
|
+
className="space-y-4"
|
|
247
|
+
role="status"
|
|
248
|
+
aria-live="polite"
|
|
249
|
+
aria-label="Loading filters"
|
|
250
|
+
>
|
|
251
|
+
<span className="sr-only">Loading filters</span>
|
|
252
|
+
{[1, 2, 3].map((i) => (
|
|
253
|
+
<div key={i} className="space-y-2" aria-hidden="true">
|
|
254
|
+
<Skeleton className="h-4 w-24" />
|
|
255
|
+
<Skeleton className="h-9 w-full" />
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</CardContent>
|
|
259
|
+
</Card>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!filters || !Array.isArray(filters) || filters.length === 0) {
|
|
264
|
+
return (
|
|
265
|
+
<Card className="w-full" role="region" aria-label="Filters panel">
|
|
266
|
+
<CardHeader>
|
|
267
|
+
<CardTitle>Filters</CardTitle>
|
|
268
|
+
</CardHeader>
|
|
269
|
+
<CardContent>
|
|
270
|
+
<p className="text-sm text-muted-foreground">No filters available</p>
|
|
271
|
+
</CardContent>
|
|
272
|
+
</Card>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<form.AppForm>
|
|
278
|
+
<FiltersForm
|
|
279
|
+
title="Filters"
|
|
280
|
+
description="Refine your search results by applying filters"
|
|
281
|
+
error={submitError}
|
|
282
|
+
success={submitSuccess}
|
|
283
|
+
onSuccessDismiss={handleSuccessDismiss}
|
|
284
|
+
submit={{
|
|
285
|
+
text: "Apply Filters",
|
|
286
|
+
loadingText: "Applying filters…",
|
|
287
|
+
}}
|
|
288
|
+
reset={{
|
|
289
|
+
text: "Reset",
|
|
290
|
+
onReset: handleReset,
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{filters.map((filter) => {
|
|
294
|
+
if (!filter || !filter.targetFieldPath) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const fieldPicklistValues = picklistValues[filter.targetFieldPath] || [];
|
|
299
|
+
const affordance = filter.affordance?.toLowerCase() || "";
|
|
300
|
+
|
|
301
|
+
if (affordance === "range") {
|
|
302
|
+
const minFieldName = `${filter.targetFieldPath}_min`;
|
|
303
|
+
const maxFieldName = `${filter.targetFieldPath}_max`;
|
|
304
|
+
const inputType = "text";
|
|
305
|
+
const placeholder =
|
|
306
|
+
filter.attributes?.placeholder === "null"
|
|
307
|
+
? undefined
|
|
308
|
+
: filter.attributes?.placeholder;
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<Field key={filter.targetFieldPath}>
|
|
312
|
+
<FieldLabel>{filter.label || filter.targetFieldPath}</FieldLabel>
|
|
313
|
+
{filter.helpMessage && <FieldDescription>{filter.helpMessage}</FieldDescription>}
|
|
314
|
+
<div
|
|
315
|
+
className="grid grid-cols-2 gap-3"
|
|
316
|
+
role="group"
|
|
317
|
+
aria-label={`${filter.label || filter.targetFieldPath} range filter`}
|
|
318
|
+
>
|
|
319
|
+
<form.AppField name={minFieldName}>
|
|
320
|
+
{(field) => (
|
|
321
|
+
<field.FilterRangeMinField
|
|
322
|
+
placeholder={placeholder || "Min"}
|
|
323
|
+
type={inputType}
|
|
324
|
+
aria-label={`${filter.label || filter.targetFieldPath} - Minimum`}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
327
|
+
</form.AppField>
|
|
328
|
+
<form.AppField name={maxFieldName}>
|
|
329
|
+
{(field) => (
|
|
330
|
+
<field.FilterRangeMaxField
|
|
331
|
+
placeholder={placeholder || "Max"}
|
|
332
|
+
type={inputType}
|
|
333
|
+
aria-label={`${filter.label || filter.targetFieldPath} - Maximum`}
|
|
334
|
+
/>
|
|
335
|
+
)}
|
|
336
|
+
</form.AppField>
|
|
337
|
+
</div>
|
|
338
|
+
</Field>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (affordance === "select") {
|
|
343
|
+
return (
|
|
344
|
+
<form.AppField key={filter.targetFieldPath} name={filter.targetFieldPath}>
|
|
345
|
+
{(field) => (
|
|
346
|
+
<field.FilterSelectField
|
|
347
|
+
label={filter.label || filter.targetFieldPath}
|
|
348
|
+
description={filter.helpMessage || undefined}
|
|
349
|
+
placeholder={filter.attributes?.placeholder || "Select..."}
|
|
350
|
+
options={fieldPicklistValues}
|
|
351
|
+
/>
|
|
352
|
+
)}
|
|
353
|
+
</form.AppField>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<form.AppField key={filter.targetFieldPath} name={filter.targetFieldPath}>
|
|
359
|
+
{(field) => (
|
|
360
|
+
<field.FilterTextField
|
|
361
|
+
label={filter.label || filter.targetFieldPath}
|
|
362
|
+
description={filter.helpMessage || undefined}
|
|
363
|
+
placeholder={
|
|
364
|
+
filter.attributes?.placeholder ||
|
|
365
|
+
`Enter ${(filter.label || filter.targetFieldPath).toLowerCase()}`
|
|
366
|
+
}
|
|
367
|
+
/>
|
|
368
|
+
)}
|
|
369
|
+
</form.AppField>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
</FiltersForm>
|
|
373
|
+
</form.AppForm>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadingFallback Component
|
|
3
|
+
*
|
|
4
|
+
* Loading fallback component for Suspense boundaries.
|
|
5
|
+
* Displays a centered spinner while lazy-loaded components are being fetched.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* - Used with React Suspense for code splitting
|
|
9
|
+
* - Simple centered spinner design
|
|
10
|
+
* - Responsive and accessible
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <Suspense fallback={<LoadingFallback />}>
|
|
15
|
+
* <LazyComponent />
|
|
16
|
+
* </Suspense>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
20
|
+
import { Spinner } from "./ui/spinner";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Spinner size variants based on content width.
|
|
24
|
+
*/
|
|
25
|
+
const spinnerVariants = cva("", {
|
|
26
|
+
variants: {
|
|
27
|
+
contentMaxWidth: {
|
|
28
|
+
sm: "size-6",
|
|
29
|
+
md: "size-8",
|
|
30
|
+
lg: "size-10",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
contentMaxWidth: "sm",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
interface LoadingFallbackProps extends VariantProps<typeof spinnerVariants> {
|
|
39
|
+
/**
|
|
40
|
+
* Maximum width of the content container. Also scales the spinner size.
|
|
41
|
+
* @default "sm"
|
|
42
|
+
*/
|
|
43
|
+
contentMaxWidth?: "sm" | "md" | "lg";
|
|
44
|
+
/**
|
|
45
|
+
* Accessible label for screen readers.
|
|
46
|
+
* @default "Loading…"
|
|
47
|
+
*/
|
|
48
|
+
loadingText?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function LoadingFallback({
|
|
52
|
+
contentMaxWidth = "sm",
|
|
53
|
+
loadingText = "Loading…",
|
|
54
|
+
}: LoadingFallbackProps) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex justify-center" role="status" aria-live="polite">
|
|
57
|
+
<Spinner className={spinnerVariants({ contentMaxWidth })} aria-hidden="true" />
|
|
58
|
+
<span className="sr-only">{loadingText}</span>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchResultCard Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a single search result as a card with primary and secondary fields.
|
|
5
|
+
* Clicking the card navigates to the detail page for that record.
|
|
6
|
+
*
|
|
7
|
+
* @param record - The search result record data to display
|
|
8
|
+
* @param columns - Array of column definitions for field display
|
|
9
|
+
* @param objectApiName - API name of the object (path param in detail URL: /object/:objectApiName/:recordId)
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* - Automatically identifies the primary field (usually "Name")
|
|
13
|
+
* - Displays up to 3 secondary fields
|
|
14
|
+
* - Supports keyboard navigation (Enter/Space to navigate)
|
|
15
|
+
* - Handles nested field values (e.g., "Owner.Alias")
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <SearchResultCard
|
|
20
|
+
* record={searchResult}
|
|
21
|
+
* columns={columns}
|
|
22
|
+
* objectApiName="Account"
|
|
23
|
+
* />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import { useNavigate } from "react-router";
|
|
27
|
+
import { useMemo, useCallback } from "react";
|
|
28
|
+
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
|
29
|
+
import type { Column, SearchResultRecordData } from "../types/search/searchResults";
|
|
30
|
+
import { getNestedFieldValue } from "../utils/fieldUtils";
|
|
31
|
+
import ResultCardFields from "./search/ResultCardFields";
|
|
32
|
+
import { OBJECT_API_NAMES } from "../constants";
|
|
33
|
+
|
|
34
|
+
interface SearchResultCardProps {
|
|
35
|
+
record: SearchResultRecordData;
|
|
36
|
+
columns: Column[];
|
|
37
|
+
objectApiName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function SearchResultCard({
|
|
41
|
+
record,
|
|
42
|
+
columns,
|
|
43
|
+
objectApiName,
|
|
44
|
+
}: SearchResultCardProps) {
|
|
45
|
+
const navigate = useNavigate();
|
|
46
|
+
|
|
47
|
+
if (!record || !record.id) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!columns || !Array.isArray(columns) || columns.length === 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!record.fields || typeof record.fields !== "object") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const detailPath = useMemo(
|
|
60
|
+
() => `/object/${objectApiName?.trim() || OBJECT_API_NAMES[0]}/${record.id}`,
|
|
61
|
+
[record.id, objectApiName],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const handleClick = useCallback(() => {
|
|
65
|
+
if (record.id) navigate(detailPath);
|
|
66
|
+
}, [record.id, detailPath, navigate]);
|
|
67
|
+
|
|
68
|
+
const handleKeyDown = useCallback(
|
|
69
|
+
(e: React.KeyboardEvent) => {
|
|
70
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
handleClick();
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[handleClick],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const primaryField = useMemo(() => {
|
|
79
|
+
return (
|
|
80
|
+
columns.find(
|
|
81
|
+
(col) =>
|
|
82
|
+
col &&
|
|
83
|
+
col.fieldApiName &&
|
|
84
|
+
(col.fieldApiName.toLowerCase() === "name" ||
|
|
85
|
+
col.fieldApiName.toLowerCase().includes("name")),
|
|
86
|
+
) ||
|
|
87
|
+
columns[0] ||
|
|
88
|
+
null
|
|
89
|
+
);
|
|
90
|
+
}, [columns]);
|
|
91
|
+
|
|
92
|
+
const primaryValue = useMemo(() => {
|
|
93
|
+
return primaryField && primaryField.fieldApiName
|
|
94
|
+
? getNestedFieldValue(record.fields, primaryField.fieldApiName) || "Untitled"
|
|
95
|
+
: "Untitled";
|
|
96
|
+
}, [primaryField, record.fields]);
|
|
97
|
+
|
|
98
|
+
const secondaryColumns = useMemo(() => {
|
|
99
|
+
return columns.filter(
|
|
100
|
+
(col) => col && col.fieldApiName && col.fieldApiName !== primaryField?.fieldApiName,
|
|
101
|
+
);
|
|
102
|
+
}, [columns, primaryField]);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Card
|
|
106
|
+
className="cursor-pointer hover:shadow-md transition-shadow focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2"
|
|
107
|
+
onClick={handleClick}
|
|
108
|
+
onKeyDown={handleKeyDown}
|
|
109
|
+
role="button"
|
|
110
|
+
tabIndex={0}
|
|
111
|
+
aria-label={`View details for ${primaryValue}`}
|
|
112
|
+
aria-describedby={`result-${record.id}-description`}
|
|
113
|
+
>
|
|
114
|
+
<CardHeader>
|
|
115
|
+
<CardTitle className="text-lg" id={`result-${record.id}-title`}>
|
|
116
|
+
{primaryValue}
|
|
117
|
+
</CardTitle>
|
|
118
|
+
</CardHeader>
|
|
119
|
+
<CardContent>
|
|
120
|
+
<div id={`result-${record.id}-description`} className="sr-only">
|
|
121
|
+
Search result: {primaryValue}
|
|
122
|
+
</div>
|
|
123
|
+
<ResultCardFields
|
|
124
|
+
record={record}
|
|
125
|
+
columns={secondaryColumns}
|
|
126
|
+
excludeFieldApiName={primaryField?.fieldApiName}
|
|
127
|
+
/>
|
|
128
|
+
</CardContent>
|
|
129
|
+
</Card>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { AlertCircle, CheckCircle } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
const statusAlertVariants = cva("", {
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
error: "",
|
|
10
|
+
success: "",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
variant: "error",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
interface StatusAlertProps extends VariantProps<typeof statusAlertVariants> {
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
/** Alert variant type. @default "error" */
|
|
21
|
+
variant?: "error" | "success";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Status alert component for displaying error or success messages.
|
|
26
|
+
* Returns null if no children are provided.
|
|
27
|
+
*/
|
|
28
|
+
export function StatusAlert({ children, variant = "error" }: StatusAlertProps) {
|
|
29
|
+
if (!children) return null;
|
|
30
|
+
|
|
31
|
+
const isError = variant === "error";
|
|
32
|
+
const descriptionId = useId();
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Alert
|
|
36
|
+
variant={isError ? "destructive" : "default"}
|
|
37
|
+
className={statusAlertVariants({ variant })}
|
|
38
|
+
aria-describedby={descriptionId}
|
|
39
|
+
role={isError ? "alert" : "status"}
|
|
40
|
+
>
|
|
41
|
+
{isError ? <AlertCircle aria-hidden="true" /> : <CheckCircle aria-hidden="true" />}
|
|
42
|
+
<AlertDescription id={descriptionId}>{children}</AlertDescription>
|
|
43
|
+
</Alert>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alternative detail rendering: columns + record → label/value list.
|
|
3
|
+
*
|
|
4
|
+
* Use when you have list columns + record (e.g. from filters-derived columns + searchResults)
|
|
5
|
+
* and do not need the Layout API. The primary detail view (DetailPage) uses DetailForm
|
|
6
|
+
* via UiApiDetailForm (layout + GraphQL record).
|
|
7
|
+
*
|
|
8
|
+
* @param record - Record data to display
|
|
9
|
+
* @param columns - Column definitions (e.g. derived from getObjectListFilters)
|
|
10
|
+
*/
|
|
11
|
+
import type { Column, SearchResultRecordData } from "../../types/search/searchResults";
|
|
12
|
+
import { getNestedFieldValue } from "../../utils/fieldUtils";
|
|
13
|
+
|
|
14
|
+
interface DetailFieldsProps {
|
|
15
|
+
record: SearchResultRecordData;
|
|
16
|
+
columns: Column[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasVisibleValue(value: string | number | boolean | null | undefined): boolean {
|
|
20
|
+
return value !== null && value !== undefined && value !== "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function DetailFields({ record, columns }: DetailFieldsProps) {
|
|
24
|
+
const rows = columns.filter(
|
|
25
|
+
(col) =>
|
|
26
|
+
col?.fieldApiName && hasVisibleValue(getNestedFieldValue(record.fields, col.fieldApiName)),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (columns.length > 0 && rows.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<div role="status" className="text-sm text-muted-foreground py-4">
|
|
32
|
+
No field values to display
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<dl className="space-y-4" role="list">
|
|
39
|
+
{rows.map((column) => {
|
|
40
|
+
const fieldApiName = column.fieldApiName as string;
|
|
41
|
+
const displayValue = getNestedFieldValue(record.fields, fieldApiName);
|
|
42
|
+
return (
|
|
43
|
+
<div key={fieldApiName} className="border-b pb-4 last:border-0" role="listitem">
|
|
44
|
+
<div className="flex flex-col sm:flex-row sm:items-start gap-2">
|
|
45
|
+
<dt className="font-semibold text-sm text-muted-foreground min-w-[150px]">
|
|
46
|
+
{column.label || fieldApiName}:
|
|
47
|
+
</dt>
|
|
48
|
+
<dd className="text-sm text-foreground flex-1">{displayValue}</dd>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</dl>
|
|
54
|
+
);
|
|
55
|
+
}
|