@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.
Files changed (92) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package-lock.json +4255 -227
  3. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/package.json +11 -2
  4. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/index.ts +19 -0
  5. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/objectDetailService.ts +125 -0
  6. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/objectInfoGraphQLService.ts +194 -0
  7. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/objectInfoService.ts +199 -0
  8. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/api/recordListGraphQLService.ts +365 -0
  9. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/FiltersPanel.tsx +375 -0
  10. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/LoadingFallback.tsx +61 -0
  11. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/SearchResultCard.tsx +131 -0
  12. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/alerts/status-alert.tsx +45 -0
  13. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailFields.tsx +55 -0
  14. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailForm.tsx +146 -0
  15. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailHeader.tsx +34 -0
  16. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/DetailLayoutSections.tsx +80 -0
  17. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/Section.tsx +108 -0
  18. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/SectionRow.tsx +20 -0
  19. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/UiApiDetailForm.tsx +140 -0
  20. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
  21. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedAddress.tsx +29 -0
  22. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedEmail.tsx +17 -0
  23. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedPhone.tsx +24 -0
  24. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedText.tsx +11 -0
  25. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/FormattedUrl.tsx +29 -0
  26. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/detail/formatted/index.ts +6 -0
  27. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/filters/FilterField.tsx +54 -0
  28. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/filters/FilterInput.tsx +55 -0
  29. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/filters/FilterSelect.tsx +72 -0
  30. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/forms/filters-form.tsx +114 -0
  31. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/forms/submit-button.tsx +47 -0
  32. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/layout/card-layout.tsx +19 -0
  33. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/ResultCardFields.tsx +71 -0
  34. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/SearchHeader.tsx +31 -0
  35. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/SearchPagination.tsx +144 -0
  36. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/search/SearchResultsPanel.tsx +197 -0
  37. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/shared/GlobalSearchInput.tsx +114 -0
  38. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/alert.tsx +69 -0
  39. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/button.tsx +67 -0
  40. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/card.tsx +92 -0
  41. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/dialog.tsx +143 -0
  42. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/field.tsx +222 -0
  43. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/index.ts +84 -0
  44. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/input.tsx +19 -0
  45. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/label.tsx +19 -0
  46. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/pagination.tsx +112 -0
  47. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/select.tsx +183 -0
  48. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/separator.tsx +26 -0
  49. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/skeleton.tsx +14 -0
  50. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/spinner.tsx +15 -0
  51. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/table.tsx +87 -0
  52. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components/ui/tabs.tsx +78 -0
  53. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/components.json +18 -0
  54. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/constants.ts +39 -0
  55. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/features/global-search/index.ts +33 -0
  56. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/form.tsx +208 -0
  57. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/index.ts +22 -0
  58. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useObjectInfoBatch.ts +65 -0
  59. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useObjectSearchData.ts +380 -0
  60. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useRecordDetailLayout.ts +156 -0
  61. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/hooks/useRecordListGraphQL.ts +135 -0
  62. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/lib/utils.ts +6 -0
  63. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/DetailPage.tsx +109 -0
  64. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/GlobalSearch.tsx +229 -0
  65. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/pages/Home.tsx +11 -10
  66. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/routes.tsx +22 -0
  67. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/styles/global.css +122 -0
  68. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/filters/filters.ts +120 -0
  69. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/filters/picklist.ts +32 -0
  70. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/index.ts +4 -0
  71. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/objectInfo/objectInfo.ts +166 -0
  72. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/recordDetail/recordDetail.ts +61 -0
  73. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/types/search/searchResults.ts +229 -0
  74. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/apiUtils.ts +125 -0
  75. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/cacheUtils.ts +76 -0
  76. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/debounce.ts +89 -0
  77. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/fieldUtils.ts +354 -0
  78. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/fieldValueExtractor.ts +67 -0
  79. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/filterUtils.ts +32 -0
  80. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/formDataTransformUtils.ts +260 -0
  81. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/formUtils.ts +142 -0
  82. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/graphQLNodeFieldUtils.ts +186 -0
  83. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/graphQLObjectInfoAdapter.ts +319 -0
  84. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/graphQLRecordAdapter.ts +90 -0
  85. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/index.ts +59 -0
  86. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/layoutTransformUtils.ts +236 -0
  87. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/linkUtils.ts +14 -0
  88. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/paginationUtils.ts +49 -0
  89. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/recordUtils.ts +159 -0
  90. package/dist/force-app/main/default/webapplications/appreacttemplateb2e/src/utils/sanitizationUtils.ts +49 -0
  91. package/dist/package.json +1 -1
  92. 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
+ }