@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,395 @@
1
+ /**
2
+ * Object Search Data Hooks
3
+ *
4
+ * - useObjectListMetadata: single source for list-view metadata (filters → columns + picklists). Use in list pages to avoid duplicate state and API calls.
5
+ * - useObjectColumns / useObjectFilters: thin wrappers over useObjectListMetadata for backward compatibility.
6
+ * - getSharedFilters: module-level deduplication for getObjectListFilters across hook instances.
7
+ */
8
+
9
+ import { useState, useEffect, useRef, useMemo } from "react";
10
+ import { objectInfoService, type SearchParams } from "../api/objectInfoService";
11
+ import type {
12
+ Column,
13
+ SearchResultRecord,
14
+ SearchResultRecordData,
15
+ } from "../types/search/searchResults";
16
+ import type { Filter, FilterCriteria } from "../types/filters/filters";
17
+ import type { PicklistValue } from "../types/filters/picklist";
18
+ import { createFiltersKey } from "../utils/cacheUtils";
19
+
20
+ // --- Shared filters cache (deduplicates getObjectListFilters across useObjectColumns + useObjectFilters) ---
21
+ const sharedFiltersCache = new Map<string, Filter[]>();
22
+ const sharedFiltersInFlight = new Map<string, Promise<Filter[]>>();
23
+
24
+ /**
25
+ * Returns filters for the object, deduplicating the API call across hook instances.
26
+ * Does not pass abort signal to the API so the shared request is not aborted when
27
+ * one consumer's effect cleans up (e.g. React Strict Mode); callers still guard with isCancelled.
28
+ */
29
+ function getSharedFilters(objectApiName: string): Promise<Filter[]> {
30
+ const cached = sharedFiltersCache.get(objectApiName);
31
+ if (cached) return Promise.resolve(cached);
32
+ const inFlight = sharedFiltersInFlight.get(objectApiName);
33
+ if (inFlight) return inFlight;
34
+ const promise = objectInfoService
35
+ .getObjectListFilters(objectApiName)
36
+ .then((filters) => {
37
+ sharedFiltersCache.set(objectApiName, filters);
38
+ sharedFiltersInFlight.delete(objectApiName);
39
+ return filters;
40
+ })
41
+ .catch((err) => {
42
+ sharedFiltersInFlight.delete(objectApiName);
43
+ throw err;
44
+ });
45
+ sharedFiltersInFlight.set(objectApiName, promise);
46
+ return promise;
47
+ }
48
+
49
+ // --- Shared Types ---
50
+ export interface FiltersData {
51
+ filters: Filter[];
52
+ picklistValues: Record<string, PicklistValue[]>;
53
+ loading: boolean;
54
+ error: string | null;
55
+ }
56
+
57
+ /**
58
+ * Derives column definitions from filter definitions for list/result UI.
59
+ */
60
+ function filtersToColumns(filters: Filter[]): Column[] {
61
+ return filters.map((f) => ({
62
+ fieldApiName: f.targetFieldPath,
63
+ label: f.label,
64
+ searchable: true,
65
+ sortable: true,
66
+ }));
67
+ }
68
+
69
+ export interface ObjectListMetadata {
70
+ columns: Column[];
71
+ filters: Filter[];
72
+ picklistValues: Record<string, PicklistValue[]>;
73
+ loading: boolean;
74
+ error: string | null;
75
+ }
76
+
77
+ /**
78
+ * Single hook for list-view metadata: filters (shared API), derived columns, and picklist values.
79
+ * Use this in list/search pages to avoid duplicate useObjectColumns + useObjectFilters and duplicate state.
80
+ */
81
+ export function useObjectListMetadata(objectApiName: string | null): ObjectListMetadata {
82
+ const [state, setState] = useState<{
83
+ columns: Column[];
84
+ filters: Filter[];
85
+ picklistValues: Record<string, PicklistValue[]>;
86
+ loading: boolean;
87
+ error: string | null;
88
+ }>({
89
+ columns: [],
90
+ filters: [],
91
+ picklistValues: {},
92
+ loading: true,
93
+ error: null,
94
+ });
95
+
96
+ useEffect(() => {
97
+ if (!objectApiName) {
98
+ setState((s) => ({ ...s, loading: false, error: "Invalid object" }));
99
+ return;
100
+ }
101
+
102
+ let isCancelled = false;
103
+ const ac = new AbortController();
104
+
105
+ const run = async () => {
106
+ setState((s) => ({ ...s, loading: true, error: null }));
107
+ try {
108
+ const filters = await getSharedFilters(objectApiName!);
109
+ if (isCancelled) return;
110
+
111
+ const selectFilters = filters.filter((f) => f.affordance?.toLowerCase() === "select");
112
+ const picklistPromises = selectFilters.map((f) =>
113
+ objectInfoService
114
+ .getPicklistValues(objectApiName!, f.targetFieldPath, undefined, ac.signal)
115
+ .then((values) => ({ fieldPath: f.targetFieldPath, values }))
116
+ .catch((err) => {
117
+ if (err?.name === "AbortError") throw err;
118
+ return { fieldPath: f.targetFieldPath, values: [] as PicklistValue[] };
119
+ }),
120
+ );
121
+ const picklistResults = await Promise.all(picklistPromises);
122
+ if (isCancelled) return;
123
+
124
+ const picklistValues: Record<string, PicklistValue[]> = {};
125
+ picklistResults.forEach(({ fieldPath, values }) => {
126
+ picklistValues[fieldPath] = values;
127
+ });
128
+
129
+ setState({
130
+ columns: filtersToColumns(filters),
131
+ filters,
132
+ picklistValues,
133
+ loading: false,
134
+ error: null,
135
+ });
136
+ } catch (err) {
137
+ if (isCancelled || (err instanceof Error && err.name === "AbortError")) return;
138
+ setState((s) => ({
139
+ ...s,
140
+ columns: [],
141
+ filters: [],
142
+ picklistValues: {},
143
+ loading: false,
144
+ error: "Failed to load list metadata",
145
+ }));
146
+ }
147
+ };
148
+
149
+ run();
150
+ return () => {
151
+ isCancelled = true;
152
+ ac.abort();
153
+ };
154
+ }, [objectApiName]);
155
+
156
+ return state;
157
+ }
158
+
159
+ /**
160
+ * Hook: useObjectColumns
161
+ * Thin wrapper over useObjectListMetadata for backward compatibility.
162
+ */
163
+ export function useObjectColumns(objectApiName: string | null) {
164
+ const { columns, loading, error } = useObjectListMetadata(objectApiName);
165
+ return {
166
+ columns: objectApiName ? columns : [],
167
+ columnsLoading: loading,
168
+ columnsError: error,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Hook: useObjectSearchResults
174
+ *
175
+ * Fetches search results for a specific object based on the provided query parameters.
176
+ * Maintains the *latest* result set for the object in state to prevent redundant
177
+ * network requests when the component re-renders with the same parameters.
178
+ * Includes debouncing for search queries (but not pagination).
179
+ *
180
+ * @param objectApiName - The API name of the object to search
181
+ * @param searchQuery - The search query string
182
+ * @param searchPageSize - Number of results per page (default: 50)
183
+ * @param searchPageToken - Pagination token (default: '0')
184
+ * @param filters - Array of filter criteria to apply (default: [])
185
+ * @param sortBy - Sort field and direction (default: 'relevance')
186
+ * @returns Object containing results array, pagination tokens, loading state, and error state
187
+ *
188
+ * @example
189
+ * ```tsx
190
+ * const { results, nextPageToken, previousPageToken, currentPageToken, resultsLoading, resultsError } = useObjectSearchResults(
191
+ * 'Account',
192
+ * 'test query',
193
+ * 25,
194
+ * '0',
195
+ * [{ objectApiName: 'Account', fieldPath: 'Name', operator: 'contains', values: ['test'] }]
196
+ * );
197
+ * ```
198
+ */
199
+ export function useObjectSearchResults(
200
+ objectApiName: string | null,
201
+ searchQuery: string,
202
+ searchPageSize: number = 50,
203
+ searchPageToken: string = "0",
204
+ filters: FilterCriteria[] = [],
205
+ sortBy: string = "relevance",
206
+ ) {
207
+ const [resultsCache, setResultsCache] = useState<
208
+ Record<
209
+ string,
210
+ {
211
+ results: SearchResultRecord[];
212
+ query: string;
213
+ pageToken: string;
214
+ pageSize: number;
215
+ filtersKey: string;
216
+ sortBy: string;
217
+ nextPageToken: string | null;
218
+ previousPageToken: string | null;
219
+ currentPageToken: string;
220
+ }
221
+ >
222
+ >({});
223
+ const [loading, setLoading] = useState<Record<string, boolean>>({});
224
+ const [error, setError] = useState<Record<string, string | null>>({});
225
+
226
+ const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
227
+ const abortControllerRef = useRef<AbortController | null>(null);
228
+ const resultsCacheRef = useRef(resultsCache);
229
+
230
+ const filtersKey = useMemo(() => {
231
+ const filtersArray = Array.isArray(filters) ? filters : [];
232
+ return createFiltersKey(filtersArray);
233
+ }, [filters]);
234
+
235
+ useEffect(() => {
236
+ resultsCacheRef.current = resultsCache;
237
+ }, [resultsCache]);
238
+
239
+ useEffect(() => {
240
+ if (!objectApiName || !searchQuery.trim()) {
241
+ return;
242
+ }
243
+
244
+ let isCancelled = false;
245
+ const abortController = new AbortController();
246
+
247
+ if (abortControllerRef.current) {
248
+ abortControllerRef.current.abort();
249
+ }
250
+ abortControllerRef.current = abortController;
251
+
252
+ if (debounceTimeout.current) {
253
+ clearTimeout(debounceTimeout.current);
254
+ debounceTimeout.current = null;
255
+ }
256
+
257
+ const cached = resultsCacheRef.current[objectApiName];
258
+ if (
259
+ !abortController.signal.aborted &&
260
+ cached &&
261
+ cached.query === searchQuery &&
262
+ cached.pageToken === searchPageToken &&
263
+ cached.pageSize === searchPageSize &&
264
+ cached.filtersKey === filtersKey &&
265
+ cached.sortBy === sortBy
266
+ ) {
267
+ return;
268
+ }
269
+
270
+ if (abortController.signal.aborted) {
271
+ return;
272
+ }
273
+
274
+ const fetchResults = async () => {
275
+ setLoading((prev) => ({ ...prev, [objectApiName]: true }));
276
+ setError((prev) => ({ ...prev, [objectApiName]: null }));
277
+
278
+ try {
279
+ const searchParams: SearchParams = {
280
+ sortBy: sortBy === "relevance" ? "" : sortBy,
281
+ filters: filters,
282
+ pageSize: searchPageSize,
283
+ pageToken: searchPageToken,
284
+ };
285
+
286
+ const keywordSearchResult = await objectInfoService.searchResults(
287
+ searchQuery,
288
+ objectApiName,
289
+ searchParams,
290
+ abortController.signal,
291
+ );
292
+
293
+ if (isCancelled || abortController.signal.aborted) return;
294
+
295
+ const normalizedRecords = keywordSearchResult.records.map((r) => ({
296
+ record: r.record as SearchResultRecordData,
297
+ highlightInfo: r.highlightInfo,
298
+ searchInfo: r.searchInfo,
299
+ }));
300
+
301
+ const nextPageToken: string | null = keywordSearchResult.nextPageToken ?? null;
302
+ const previousPageToken: string | null = keywordSearchResult.previousPageToken ?? null;
303
+
304
+ setResultsCache((prev): typeof prev => ({
305
+ ...prev,
306
+ [objectApiName]: {
307
+ results: normalizedRecords,
308
+ query: searchQuery,
309
+ pageToken: searchPageToken,
310
+ pageSize: searchPageSize,
311
+ filtersKey: filtersKey,
312
+ sortBy,
313
+ nextPageToken,
314
+ previousPageToken,
315
+ currentPageToken: keywordSearchResult.currentPageToken,
316
+ },
317
+ }));
318
+ } catch (err) {
319
+ if (isCancelled || (err instanceof Error && err.name === "AbortError")) {
320
+ return;
321
+ }
322
+ setError((prev) => ({ ...prev, [objectApiName]: "Unable to load search results" }));
323
+ // Cache empty result so we skip refetch on remount (avoid infinite loop on API error)
324
+ setResultsCache((prev) => ({
325
+ ...prev,
326
+ [objectApiName]: {
327
+ results: [],
328
+ query: searchQuery,
329
+ pageToken: searchPageToken,
330
+ pageSize: searchPageSize,
331
+ filtersKey: filtersKey,
332
+ sortBy,
333
+ nextPageToken: null,
334
+ previousPageToken: null,
335
+ currentPageToken: searchPageToken,
336
+ },
337
+ }));
338
+ } finally {
339
+ if (!isCancelled) {
340
+ setLoading((prev) => ({ ...prev, [objectApiName]: false }));
341
+ }
342
+ }
343
+ };
344
+
345
+ if (searchPageToken === "0") {
346
+ debounceTimeout.current = setTimeout(() => {
347
+ fetchResults();
348
+ }, 300);
349
+ } else {
350
+ fetchResults();
351
+ }
352
+
353
+ return () => {
354
+ isCancelled = true;
355
+ abortController.abort();
356
+ if (debounceTimeout.current) {
357
+ clearTimeout(debounceTimeout.current);
358
+ debounceTimeout.current = null;
359
+ }
360
+ if (abortControllerRef.current === abortController) {
361
+ abortControllerRef.current = null;
362
+ }
363
+ };
364
+ }, [objectApiName, searchQuery, searchPageSize, searchPageToken, filtersKey, sortBy]);
365
+
366
+ return {
367
+ results: objectApiName ? resultsCache[objectApiName]?.results || [] : [],
368
+ nextPageToken: objectApiName ? resultsCache[objectApiName]?.nextPageToken || null : null,
369
+ previousPageToken: objectApiName
370
+ ? resultsCache[objectApiName]?.previousPageToken || null
371
+ : null,
372
+ currentPageToken: objectApiName ? resultsCache[objectApiName]?.currentPageToken || "0" : "0",
373
+ resultsLoading: objectApiName ? loading[objectApiName] || false : false,
374
+ resultsError: objectApiName ? error[objectApiName] || null : null,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Hook: useObjectFilters
380
+ * Thin wrapper over useObjectListMetadata for backward compatibility.
381
+ */
382
+ export function useObjectFilters(objectApiName: string | null) {
383
+ const { filters, picklistValues, loading, error } = useObjectListMetadata(objectApiName);
384
+ const filtersData: Record<string, FiltersData> = objectApiName
385
+ ? {
386
+ [objectApiName]: {
387
+ filters,
388
+ picklistValues,
389
+ loading,
390
+ error,
391
+ },
392
+ }
393
+ : {};
394
+ return { filtersData };
395
+ }
@@ -0,0 +1,156 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { objectDetailService } from "../api/objectDetailService";
3
+ import type { LayoutResponse } from "../types/recordDetail/recordDetail";
4
+ import type { ObjectInfoResult } from "../types/objectInfo/objectInfo";
5
+ import type { GraphQLRecordNode } from "../api/recordListGraphQLService";
6
+
7
+ export interface UseRecordDetailLayoutReturn {
8
+ layout: LayoutResponse | null;
9
+ record: GraphQLRecordNode | null;
10
+ objectMetadata: ObjectInfoResult | null;
11
+ loading: boolean;
12
+ error: string | null;
13
+ }
14
+
15
+ export interface UseRecordDetailLayoutParams {
16
+ objectApiName: string | null;
17
+ recordId: string | null;
18
+ recordTypeId?: string | null;
19
+ /** When provided, skips the fetch and uses this data (avoids duplicate API calls when parent already fetched). Callers should memoize this (e.g. useMemo) to avoid unnecessary effect runs. */
20
+ initialData?: {
21
+ layout: LayoutResponse;
22
+ record: GraphQLRecordNode;
23
+ objectMetadata: ObjectInfoResult;
24
+ } | null;
25
+ }
26
+
27
+ const MAX_CACHE_SIZE = 50;
28
+ /** Cache entries older than this are treated as stale and refetched. */
29
+ const CACHE_TTL_MS = 5 * 60 * 1000;
30
+
31
+ type CacheEntry = {
32
+ layout: LayoutResponse;
33
+ record: GraphQLRecordNode;
34
+ objectMetadata: ObjectInfoResult;
35
+ cachedAt: number;
36
+ };
37
+
38
+ /**
39
+ * Detail page data: layout (REST), object metadata (GraphQL), single record (GraphQL).
40
+ *
41
+ * Calls objectDetailService.getRecordDetail once per objectApiName/recordId/recordTypeId.
42
+ * Caches result in memory (TTL 5min, max 50 entries). Used by DetailPage and UiApiDetailForm.
43
+ *
44
+ * @param objectApiName - Object API name.
45
+ * @param recordId - Record Id.
46
+ * @param recordTypeId - Optional record type (default master).
47
+ * @returns { layout, record, objectMetadata, loading, error }.
48
+ */
49
+ export function useRecordDetailLayout({
50
+ objectApiName,
51
+ recordId,
52
+ recordTypeId = null,
53
+ initialData = null,
54
+ }: UseRecordDetailLayoutParams): UseRecordDetailLayoutReturn {
55
+ const [layout, setLayout] = useState<LayoutResponse | null>(initialData?.layout ?? null);
56
+ const [record, setRecord] = useState<GraphQLRecordNode | null>(initialData?.record ?? null);
57
+ const [objectMetadata, setObjectMetadata] = useState<ObjectInfoResult | null>(
58
+ initialData?.objectMetadata ?? null,
59
+ );
60
+ const [loading, setLoading] = useState(!initialData);
61
+ const [error, setError] = useState<string | null>(null);
62
+
63
+ const cacheKey =
64
+ objectApiName && recordId ? `${objectApiName}:${recordId}:${recordTypeId ?? "default"}` : null;
65
+ const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
66
+
67
+ useEffect(() => {
68
+ if (!objectApiName || !recordId) {
69
+ setError("Invalid object or record ID");
70
+ setLoading(false);
71
+ return;
72
+ }
73
+
74
+ // Skip fetch when parent already provided data (avoids duplicate API calls)
75
+ if (
76
+ initialData?.layout != null &&
77
+ initialData?.record != null &&
78
+ initialData?.objectMetadata != null
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ const cached = cacheRef.current.get(cacheKey!);
84
+ const now = Date.now();
85
+ if (cached && now - cached.cachedAt < CACHE_TTL_MS) {
86
+ setLayout(cached.layout);
87
+ setRecord(cached.record);
88
+ setObjectMetadata(cached.objectMetadata);
89
+ setLoading(false);
90
+ setError(null);
91
+ return;
92
+ }
93
+
94
+ let isCancelled = false;
95
+ const abortController = new AbortController();
96
+
97
+ const fetchDetail = async () => {
98
+ setLoading(true);
99
+ setError(null);
100
+
101
+ try {
102
+ const {
103
+ layout: layoutData,
104
+ record: recordData,
105
+ objectMetadata: objectMetadataData,
106
+ } = await objectDetailService.getRecordDetail(
107
+ objectApiName,
108
+ recordId,
109
+ recordTypeId ?? undefined,
110
+ abortController.signal,
111
+ );
112
+
113
+ if (isCancelled) return;
114
+
115
+ const cache = cacheRef.current;
116
+ if (cache.size >= MAX_CACHE_SIZE) {
117
+ const firstKey = cache.keys().next().value;
118
+ if (firstKey != null) cache.delete(firstKey);
119
+ }
120
+ cache.set(cacheKey!, {
121
+ layout: layoutData,
122
+ record: recordData,
123
+ objectMetadata: objectMetadataData,
124
+ cachedAt: Date.now(),
125
+ });
126
+ setLayout(layoutData);
127
+ setRecord(recordData);
128
+ setObjectMetadata(objectMetadataData);
129
+ } catch (err) {
130
+ if (isCancelled || (err instanceof Error && err.name === "AbortError")) {
131
+ return;
132
+ }
133
+ setError("Failed to load record details");
134
+ } finally {
135
+ if (!isCancelled) {
136
+ setLoading(false);
137
+ }
138
+ }
139
+ };
140
+
141
+ fetchDetail();
142
+
143
+ return () => {
144
+ isCancelled = true;
145
+ abortController.abort();
146
+ };
147
+ }, [objectApiName, recordId, recordTypeId, cacheKey, initialData]);
148
+
149
+ return {
150
+ layout,
151
+ record,
152
+ objectMetadata,
153
+ loading,
154
+ error,
155
+ };
156
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Record list hook: GraphQL records with filter, sort, pagination, search.
3
+ * Use for list/search views; detail view uses useRecordDetailLayout instead.
4
+ *
5
+ * @module hooks/useRecordListGraphQL
6
+ */
7
+
8
+ import { useState, useEffect, useCallback } from "react";
9
+ import { useObjectColumns } from "./useObjectSearchData";
10
+ import {
11
+ getRecordsGraphQL,
12
+ buildOrderByFromSort,
13
+ type RecordListGraphQLResult,
14
+ } from "../api/recordListGraphQLService";
15
+ import type { Column } from "../types/search/searchResults";
16
+ import type { FilterCriteria } from "../types/filters/filters";
17
+
18
+ const EMPTY_FILTERS: FilterCriteria[] = [];
19
+
20
+ export interface UseRecordListGraphQLOptions {
21
+ objectApiName: string;
22
+ first?: number;
23
+ after?: string | null;
24
+ filters?: FilterCriteria[];
25
+ sortBy?: string;
26
+ searchQuery?: string;
27
+ /** When provided, skips useObjectColumns (use from parent e.g. useObjectListMetadata). */
28
+ columns?: Column[];
29
+ columnsLoading?: boolean;
30
+ columnsError?: string | null;
31
+ }
32
+
33
+ export interface UseRecordListGraphQLReturn {
34
+ data: RecordListGraphQLResult | null;
35
+ edges: Array<{ node?: Record<string, unknown> }>;
36
+ pageInfo: {
37
+ hasNextPage?: boolean;
38
+ hasPreviousPage?: boolean;
39
+ endCursor?: string | null;
40
+ startCursor?: string | null;
41
+ } | null;
42
+ loading: boolean;
43
+ error: string | null;
44
+ columnsLoading: boolean;
45
+ columnsError: string | null;
46
+ refetch: () => void;
47
+ }
48
+
49
+ /**
50
+ * Fetches records via GraphQL for the given object with filter, sort, pagination, and search.
51
+ */
52
+ export function useRecordListGraphQL(
53
+ options: UseRecordListGraphQLOptions,
54
+ ): UseRecordListGraphQLReturn {
55
+ const {
56
+ objectApiName,
57
+ first = 50,
58
+ after = null,
59
+ filters = EMPTY_FILTERS,
60
+ sortBy = "",
61
+ searchQuery = "",
62
+ columns: columnsProp,
63
+ columnsLoading: columnsLoadingProp,
64
+ columnsError: columnsErrorProp,
65
+ } = options;
66
+
67
+ const fromParent = columnsProp !== undefined;
68
+ const fromHook = useObjectColumns(fromParent ? null : objectApiName);
69
+
70
+ const columns = fromParent ? columnsProp : fromHook.columns;
71
+ const columnsLoading = fromParent ? (columnsLoadingProp ?? false) : fromHook.columnsLoading;
72
+ const columnsError = fromParent ? (columnsErrorProp ?? null) : fromHook.columnsError;
73
+
74
+ const [data, setData] = useState<RecordListGraphQLResult | null>(null);
75
+ const [loading, setLoading] = useState(false);
76
+ const [error, setError] = useState<string | null>(null);
77
+
78
+ const fetchRecords = useCallback(() => {
79
+ if (columnsLoading || columnsError || columns.length === 0) return;
80
+
81
+ setLoading(true);
82
+ setError(null);
83
+ const orderBy = buildOrderByFromSort(sortBy);
84
+
85
+ getRecordsGraphQL({
86
+ objectApiName,
87
+ columns,
88
+ first,
89
+ after,
90
+ filters,
91
+ orderBy,
92
+ searchQuery: searchQuery.trim() || undefined,
93
+ })
94
+ .then((result) => {
95
+ setData(result);
96
+ })
97
+ .catch((err) => {
98
+ setError(err instanceof Error ? err.message : "Failed to load records");
99
+ })
100
+ .finally(() => {
101
+ setLoading(false);
102
+ });
103
+ }, [
104
+ objectApiName,
105
+ columns,
106
+ columnsLoading,
107
+ columnsError,
108
+ first,
109
+ after,
110
+ filters,
111
+ sortBy,
112
+ searchQuery,
113
+ ]);
114
+
115
+ useEffect(() => {
116
+ if (!objectApiName || columnsLoading || columnsError) return;
117
+ if (columns.length === 0 && !columnsLoading) return;
118
+ fetchRecords();
119
+ }, [objectApiName, columns, columnsLoading, columnsError, fetchRecords]);
120
+
121
+ const objectData = data?.uiapi?.query?.[objectApiName];
122
+ const edges = objectData?.edges ?? [];
123
+ const pageInfo = objectData?.pageInfo ?? null;
124
+
125
+ return {
126
+ data,
127
+ edges,
128
+ pageInfo,
129
+ loading: columnsLoading || loading,
130
+ error: columnsError || error,
131
+ columnsLoading,
132
+ columnsError,
133
+ refetch: fetchRecords,
134
+ };
135
+ }