@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.
Files changed (117) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/objects/Maintenance_Request__c/Maintenance_Request__c.object-meta.xml +11 -1
  3. package/dist/force-app/main/default/objects/Maintenance_Worker__c/Maintenance_Worker__c.object-meta.xml +6 -1
  4. package/dist/force-app/main/default/objects/Property__c/Property__c.object-meta.xml +6 -1
  5. package/dist/force-app/main/default/webapplications/appreactsampleb2e/package.json +7 -5
  6. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/api/maintenanceWorkers.ts +60 -0
  7. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ApplicationsTable.tsx +59 -62
  8. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/FiltersFromApi.tsx +200 -0
  9. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ListPageFilters.tsx +97 -0
  10. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/MaintenanceTable.tsx +2 -1
  11. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/ObjectSelect.tsx +39 -0
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/VerticalNav.tsx +6 -4
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/dashboard/GlobalSearchBar.tsx +125 -0
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/FilterErrorAlert.tsx +15 -0
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/PageErrorState.tsx +19 -0
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/feedback/PageLoadingState.tsx +18 -0
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldRange.tsx +40 -0
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldSelect.tsx +190 -0
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/FilterFieldText.tsx +32 -0
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/filters/ListPageFilterRow.tsx +100 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/layout/PageContainer.tsx +9 -0
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/layout/PageHeader.tsx +21 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/components/list/ListPageWithFilters.tsx +70 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/constants.ts +39 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/index.ts +19 -0
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectDetailService.ts +125 -0
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectInfoGraphQLService.ts +194 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/objectInfoService.ts +199 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/api/recordListGraphQLService.ts +364 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailFields.tsx +55 -0
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailForm.tsx +146 -0
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailHeader.tsx +34 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/DetailLayoutSections.tsx +80 -0
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/Section.tsx +108 -0
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/SectionRow.tsx +20 -0
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/UiApiDetailForm.tsx +140 -0
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FieldValueDisplay.tsx +73 -0
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedAddress.tsx +29 -0
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedEmail.tsx +17 -0
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedPhone.tsx +24 -0
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedText.tsx +11 -0
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/FormattedUrl.tsx +29 -0
  43. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/detail/formatted/index.ts +6 -0
  44. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterField.tsx +54 -0
  45. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterInput.tsx +55 -0
  46. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FilterSelect.tsx +72 -0
  47. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/filters/FiltersPanel.tsx +380 -0
  48. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/forms/filters-form.tsx +114 -0
  49. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/forms/submit-button.tsx +47 -0
  50. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/GlobalSearchInput.tsx +114 -0
  51. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/ResultCardFields.tsx +71 -0
  52. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchHeader.tsx +31 -0
  53. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchPagination.tsx +144 -0
  54. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchResultCard.tsx +136 -0
  55. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/search/SearchResultsPanel.tsx +197 -0
  56. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/components/shared/LoadingFallback.tsx +61 -0
  57. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/filters/FilterInput.tsx +55 -0
  58. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/filters/FilterSelect.tsx +72 -0
  59. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/form.tsx +209 -0
  60. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/index.ts +22 -0
  61. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useObjectInfoBatch.ts +65 -0
  62. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useObjectSearchData.ts +395 -0
  63. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useRecordDetailLayout.ts +156 -0
  64. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/hooks/useRecordListGraphQL.ts +135 -0
  65. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/pages/DetailPage.tsx +109 -0
  66. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/pages/GlobalSearch.tsx +229 -0
  67. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/filters/filters.ts +121 -0
  68. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/filters/picklist.ts +32 -0
  69. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/index.ts +5 -0
  70. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/objectInfo/objectInfo.ts +166 -0
  71. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/recordDetail/recordDetail.ts +61 -0
  72. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/types/search/searchResults.ts +229 -0
  73. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/apiUtils.ts +125 -0
  74. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/cacheUtils.ts +76 -0
  75. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/debounce.ts +89 -0
  76. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/fieldUtils.ts +354 -0
  77. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/fieldValueExtractor.ts +67 -0
  78. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/filterUtils.ts +32 -0
  79. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/formDataTransformUtils.ts +260 -0
  80. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/formUtils.ts +142 -0
  81. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLNodeFieldUtils.ts +186 -0
  82. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLObjectInfoAdapter.ts +319 -0
  83. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/graphQLRecordAdapter.ts +90 -0
  84. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/index.ts +59 -0
  85. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/layoutTransformUtils.ts +236 -0
  86. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/linkUtils.ts +14 -0
  87. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/paginationUtils.ts +49 -0
  88. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/recordUtils.ts +159 -0
  89. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/features/global-search/utils/sanitizationUtils.ts +49 -0
  90. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/hooks/useAccumulatedListPages.ts +29 -0
  91. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/hooks/useListPage.ts +167 -0
  92. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/index.ts +8 -4
  93. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/applicationAdapter.ts +33 -0
  94. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/applicationColumns.ts +28 -0
  95. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/constants.ts +24 -0
  96. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/fieldMappers.ts +71 -0
  97. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/filterUtils.ts +165 -0
  98. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/globalSearchConstants.ts +40 -0
  99. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/listFilters.ts +152 -0
  100. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/listPageConfig.ts +65 -0
  101. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceAdapter.ts +110 -0
  102. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceColumns.ts +24 -0
  103. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceWorkerAdapter.ts +29 -0
  104. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/maintenanceWorkerColumns.ts +25 -0
  105. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/objectApiNames.ts +13 -0
  106. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/propertyAdapter.ts +68 -0
  107. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/propertyColumns.ts +17 -0
  108. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/routeConfig.ts +35 -0
  109. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/lib/types.ts +10 -0
  110. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Applications.tsx +47 -62
  111. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Home.tsx +130 -98
  112. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Maintenance.tsx +74 -91
  113. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/MaintenanceWorkers.tsx +138 -0
  114. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/pages/Properties.tsx +166 -85
  115. package/dist/force-app/main/default/webapplications/appreactsampleb2e/src/routes.tsx +41 -2
  116. package/dist/package.json +1 -1
  117. 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
+ }