@firecms/core 3.0.1 → 3.1.0-canary.1df3b2c

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 (170) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AIIcon.d.ts +16 -0
  3. package/dist/components/EntityCollectionTable/EntityCollectionRowActions.d.ts +7 -1
  4. package/dist/components/EntityCollectionTable/EntityCollectionTable.d.ts +1 -1
  5. package/dist/components/EntityCollectionTable/EntityCollectionTableProps.d.ts +14 -0
  6. package/dist/components/EntityCollectionTable/PropertyTableCell.d.ts +6 -0
  7. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +5 -4
  8. package/dist/components/EntityCollectionTable/internal/EntityTableCell.d.ts +6 -0
  9. package/dist/components/EntityCollectionView/Board.d.ts +2 -0
  10. package/dist/components/EntityCollectionView/BoardColumn.d.ts +42 -0
  11. package/dist/components/EntityCollectionView/BoardColumnTitle.d.ts +9 -0
  12. package/dist/components/EntityCollectionView/BoardSortableList.d.ts +14 -0
  13. package/dist/components/EntityCollectionView/EntityBoardCard.d.ts +26 -0
  14. package/dist/components/EntityCollectionView/EntityCard.d.ts +19 -0
  15. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +20 -0
  16. package/dist/components/EntityCollectionView/EntityCollectionCardView.d.ts +31 -0
  17. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +2 -2
  18. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +7 -3
  19. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +14 -0
  20. package/dist/components/EntityCollectionView/ViewModeToggle.d.ts +49 -0
  21. package/dist/components/EntityCollectionView/board_types.d.ts +105 -0
  22. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +60 -0
  23. package/dist/components/SelectableTable/SelectableTable.d.ts +5 -1
  24. package/dist/components/SelectableTable/filters/DateTimeFilterField.d.ts +2 -1
  25. package/dist/components/VirtualTable/VirtualTableCell.d.ts +6 -0
  26. package/dist/components/VirtualTable/VirtualTableHeader.d.ts +2 -0
  27. package/dist/components/VirtualTable/VirtualTableHeaderRow.d.ts +1 -1
  28. package/dist/components/VirtualTable/VirtualTableProps.d.ts +11 -0
  29. package/dist/components/VirtualTable/fields/VirtualTableDateField.d.ts +1 -0
  30. package/dist/components/VirtualTable/types.d.ts +2 -0
  31. package/dist/components/index.d.ts +3 -0
  32. package/dist/contexts/index.d.ts +10 -0
  33. package/dist/core/DrawerNavigationGroup.d.ts +45 -0
  34. package/dist/core/index.d.ts +1 -0
  35. package/dist/form/validation.d.ts +3 -2
  36. package/dist/hooks/useBreadcrumbsController.d.ts +16 -0
  37. package/dist/hooks/useCollapsedGroups.d.ts +4 -1
  38. package/dist/index.es.js +5239 -1590
  39. package/dist/index.es.js.map +1 -1
  40. package/dist/index.umd.js +5233 -1585
  41. package/dist/index.umd.js.map +1 -1
  42. package/dist/preview/PropertyPreviewProps.d.ts +5 -0
  43. package/dist/preview/components/DatePreview.d.ts +13 -3
  44. package/dist/preview/components/ImagePreview.d.ts +5 -1
  45. package/dist/preview/components/StorageThumbnail.d.ts +2 -1
  46. package/dist/preview/components/UrlComponentPreview.d.ts +2 -1
  47. package/dist/preview/property_previews/ArrayOfStorageComponentsPreview.d.ts +1 -1
  48. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  49. package/dist/preview/property_previews/SkeletonPropertyComponent.d.ts +1 -1
  50. package/dist/types/collections.d.ts +42 -2
  51. package/dist/types/datasource.d.ts +0 -1
  52. package/dist/types/plugins.d.ts +46 -1
  53. package/dist/types/properties.d.ts +259 -4
  54. package/dist/util/__tests__/conditions.test.d.ts +1 -0
  55. package/dist/util/__tests__/objects.test.d.ts +1 -0
  56. package/dist/util/conditions.d.ts +26 -0
  57. package/dist/util/entities.d.ts +1 -2
  58. package/dist/util/index.d.ts +2 -1
  59. package/dist/util/property_utils.d.ts +2 -1
  60. package/dist/util/resolutions.d.ts +1 -1
  61. package/package.json +10 -7
  62. package/src/app/Scaffold.tsx +14 -15
  63. package/src/components/AIIcon.tsx +39 -0
  64. package/src/components/ArrayContainer.tsx +1 -4
  65. package/src/components/ClearFilterSortButton.tsx +19 -16
  66. package/src/components/ConfirmationDialog.tsx +0 -2
  67. package/src/components/DeleteEntityDialog.tsx +2 -4
  68. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +74 -41
  69. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +130 -79
  70. package/src/components/EntityCollectionTable/EntityCollectionTableProps.tsx +121 -104
  71. package/src/components/EntityCollectionTable/PropertyTableCell.tsx +132 -103
  72. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +20 -42
  73. package/src/components/EntityCollectionTable/internal/EntityTableCell.tsx +90 -49
  74. package/src/components/EntityCollectionView/Board.tsx +324 -0
  75. package/src/components/EntityCollectionView/BoardColumn.tsx +158 -0
  76. package/src/components/EntityCollectionView/BoardColumnTitle.tsx +45 -0
  77. package/src/components/EntityCollectionView/BoardSortableList.tsx +172 -0
  78. package/src/components/EntityCollectionView/EntityBoardCard.tsx +212 -0
  79. package/src/components/EntityCollectionView/EntityCard.tsx +231 -0
  80. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +713 -0
  81. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +244 -0
  82. package/src/components/EntityCollectionView/EntityCollectionView.tsx +485 -203
  83. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +31 -19
  84. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +84 -15
  85. package/src/components/EntityCollectionView/FiltersDialog.tsx +249 -0
  86. package/src/components/EntityCollectionView/ViewModeToggle.tsx +202 -0
  87. package/src/components/EntityCollectionView/board_types.ts +113 -0
  88. package/src/components/EntityCollectionView/useBoardDataController.tsx +490 -0
  89. package/src/components/ErrorTooltip.tsx +2 -1
  90. package/src/components/HomePage/DefaultHomePage.tsx +47 -10
  91. package/src/components/HomePage/HomePageDnD.tsx +56 -41
  92. package/src/components/HomePage/NavigationCard.tsx +20 -18
  93. package/src/components/HomePage/NavigationGroup.tsx +17 -16
  94. package/src/components/HomePage/RenameGroupDialog.tsx +0 -2
  95. package/src/components/HomePage/SmallNavigationCard.tsx +10 -9
  96. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +3 -10
  97. package/src/components/ReferenceWidget.tsx +2 -4
  98. package/src/components/SelectableTable/SelectableTable.tsx +75 -67
  99. package/src/components/SelectableTable/filters/BooleanFilterField.tsx +7 -6
  100. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +39 -40
  101. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +38 -38
  102. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +49 -58
  103. package/src/components/UnsavedChangesDialog.tsx +0 -2
  104. package/src/components/UserDisplay.tsx +4 -4
  105. package/src/components/VirtualTable/VirtualTable.tsx +170 -19
  106. package/src/components/VirtualTable/VirtualTableCell.tsx +18 -2
  107. package/src/components/VirtualTable/VirtualTableHeader.tsx +20 -11
  108. package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +158 -42
  109. package/src/components/VirtualTable/VirtualTableProps.tsx +14 -1
  110. package/src/components/VirtualTable/VirtualTableRow.tsx +1 -1
  111. package/src/components/VirtualTable/fields/VirtualTableDateField.tsx +3 -0
  112. package/src/components/VirtualTable/fields/VirtualTableSelect.tsx +17 -4
  113. package/src/components/VirtualTable/types.tsx +2 -0
  114. package/src/components/common/useColumnsIds.tsx +95 -3
  115. package/src/components/index.tsx +4 -0
  116. package/src/contexts/BreacrumbsContext.tsx +15 -8
  117. package/src/contexts/index.ts +10 -0
  118. package/src/core/DefaultAppBar.tsx +39 -26
  119. package/src/core/DefaultDrawer.tsx +42 -56
  120. package/src/core/DrawerNavigationGroup.tsx +118 -0
  121. package/src/core/DrawerNavigationItem.tsx +4 -3
  122. package/src/core/EntityEditView.tsx +41 -43
  123. package/src/core/SideDialogs.tsx +4 -2
  124. package/src/core/index.tsx +1 -0
  125. package/src/form/PropertyFieldBinding.tsx +58 -43
  126. package/src/form/components/StorageItemPreview.tsx +2 -1
  127. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +0 -1
  128. package/src/form/field_bindings/DateTimeFieldBinding.tsx +17 -16
  129. package/src/form/field_bindings/KeyValueFieldBinding.tsx +0 -1
  130. package/src/form/field_bindings/MapFieldBinding.tsx +69 -67
  131. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +21 -17
  132. package/src/form/field_bindings/TextFieldBinding.tsx +71 -35
  133. package/src/form/validation.ts +245 -160
  134. package/src/hooks/useBreadcrumbsController.tsx +18 -0
  135. package/src/hooks/useBuildNavigationController.tsx +42 -19
  136. package/src/hooks/useCollapsedGroups.ts +12 -4
  137. package/src/internal/useBuildDataSource.ts +69 -34
  138. package/src/internal/useBuildSideDialogsController.tsx +11 -8
  139. package/src/internal/useBuildSideEntityController.tsx +2 -4
  140. package/src/internal/useRestoreScroll.tsx +26 -14
  141. package/src/preview/PropertyPreview.tsx +40 -32
  142. package/src/preview/PropertyPreviewProps.tsx +6 -0
  143. package/src/preview/components/DatePreview.tsx +72 -4
  144. package/src/preview/components/EmptyValue.tsx +1 -1
  145. package/src/preview/components/ImagePreview.tsx +37 -21
  146. package/src/preview/components/StorageThumbnail.tsx +16 -12
  147. package/src/preview/components/UrlComponentPreview.tsx +28 -25
  148. package/src/preview/property_previews/ArrayOfStorageComponentsPreview.tsx +9 -7
  149. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +11 -9
  150. package/src/preview/property_previews/ArrayPropertyPreview.tsx +26 -24
  151. package/src/preview/property_previews/SkeletonPropertyComponent.tsx +61 -56
  152. package/src/routes/CustomCMSRoute.tsx +1 -0
  153. package/src/routes/FireCMSRoute.tsx +26 -13
  154. package/src/types/collections.ts +48 -3
  155. package/src/types/datasource.ts +54 -56
  156. package/src/types/plugins.tsx +51 -1
  157. package/src/types/properties.ts +347 -27
  158. package/src/util/__tests__/conditions.test.ts +506 -0
  159. package/src/util/__tests__/objects.test.ts +196 -0
  160. package/src/util/callbacks.ts +6 -3
  161. package/src/util/collections.ts +51 -6
  162. package/src/util/conditions.ts +339 -0
  163. package/src/util/entities.ts +28 -29
  164. package/src/util/entity_cache.ts +2 -1
  165. package/src/util/index.ts +2 -1
  166. package/src/util/objects.ts +31 -13
  167. package/src/util/{references.ts → previews.ts} +14 -0
  168. package/src/util/property_utils.tsx +36 -10
  169. package/src/util/resolutions.ts +57 -55
  170. /package/dist/util/{references.d.ts → previews.d.ts} +0 -0
@@ -0,0 +1,490 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Entity, EntityCollection, FilterValues } from "../../types";
3
+ import { useDataSource, useFireCMSContext, useNavigationController } from "../../hooks";
4
+
5
+ const DEFAULT_PAGE_SIZE = 20;
6
+
7
+ /**
8
+ * Data state for a single board column
9
+ */
10
+ export interface BoardColumnData<M extends Record<string, any> = any> {
11
+ /** Entities loaded for this column */
12
+ entities: Entity<M>[];
13
+ /** Whether the column is currently loading data */
14
+ loading: boolean;
15
+ /** Whether there are more items to load */
16
+ hasMore: boolean;
17
+ /** Error if loading failed */
18
+ error?: Error;
19
+ /** Total count of entities in this column */
20
+ totalCount?: number;
21
+ }
22
+
23
+ /**
24
+ * Controller for managing per-column data in a Kanban board
25
+ */
26
+ export interface BoardDataController<M extends Record<string, any> = any, COLUMN extends string = string> {
27
+ /** Data state for each column */
28
+ columnData: Record<COLUMN, BoardColumnData<M>>;
29
+ /** Load more items for a specific column */
30
+ loadMoreColumn: (column: COLUMN) => void;
31
+ /** Refresh data for a specific column */
32
+ refreshColumn: (column: COLUMN) => void;
33
+ /** Refresh all columns */
34
+ refreshAll: () => void;
35
+ /** Update counts for columns (for optimistic updates when moving items) */
36
+ updateColumnCounts: (sourceColumn: COLUMN, targetColumn: COLUMN) => void;
37
+ /** Decrement column counts (for optimistic updates when deleting items) */
38
+ decrementColumnCounts: (columnDeltas: Record<COLUMN, number>) => void;
39
+ /** Whether any column is loading */
40
+ loading: boolean;
41
+ /** Any error from any column */
42
+ error?: Error;
43
+ }
44
+
45
+ export interface UseBoardDataControllerProps<M extends Record<string, any> = any> {
46
+ /** Full path to the collection */
47
+ fullPath: string;
48
+ /** The entity collection configuration */
49
+ collection: EntityCollection<M>;
50
+ /** Property key used for column assignment */
51
+ columnProperty: string;
52
+ /** Array of column values (enum values from columnProperty) */
53
+ columns: string[];
54
+ /** Property key used for ordering within columns */
55
+ orderProperty?: string;
56
+ /** Number of items to load per page per column */
57
+ pageSize?: number;
58
+ /** Text search string to filter entities */
59
+ searchString?: string;
60
+ /** Additional filter values */
61
+ filterValues?: FilterValues<string>;
62
+ }
63
+
64
+ /**
65
+ * Hook that manages per-column data loading for the Kanban board.
66
+ * Each column gets its own independent query to the data source.
67
+ */
68
+ export function useBoardDataController<M extends Record<string, any> = any, COLUMN extends string = string>({
69
+ fullPath,
70
+ collection,
71
+ columnProperty,
72
+ columns,
73
+ orderProperty,
74
+ pageSize = DEFAULT_PAGE_SIZE,
75
+ searchString,
76
+ filterValues
77
+ }: UseBoardDataControllerProps<M>): BoardDataController<M, COLUMN> {
78
+
79
+ const context = useFireCMSContext();
80
+ const dataSource = useDataSource(collection);
81
+ const navigation = useNavigationController();
82
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
83
+
84
+ // Stable refs for objects that shouldn't trigger re-subscriptions
85
+ const dataSourceRef = useRef(dataSource);
86
+ const collectionRef = useRef(collection);
87
+ const contextRef = useRef(context);
88
+ dataSourceRef.current = dataSource;
89
+ collectionRef.current = collection;
90
+ contextRef.current = context;
91
+
92
+ // Store filter/order params in refs so they're accessible without causing re-subscriptions
93
+ const filterValuesRef = useRef(filterValues);
94
+ const columnPropertyRef = useRef(columnProperty);
95
+ const orderPropertyRef = useRef(orderProperty);
96
+ const searchStringRef = useRef(searchString);
97
+ const resolvedPathRef = useRef(resolvedPath);
98
+ filterValuesRef.current = filterValues;
99
+ columnPropertyRef.current = columnProperty;
100
+ orderPropertyRef.current = orderProperty;
101
+ searchStringRef.current = searchString;
102
+ resolvedPathRef.current = resolvedPath;
103
+
104
+ // Track item count per column for pagination
105
+ const [columnItemCounts, setColumnItemCounts] = useState<Record<string, number>>(() => {
106
+ const initial: Record<string, number> = {};
107
+ columns.forEach(col => {
108
+ initial[col] = pageSize;
109
+ });
110
+ return initial;
111
+ });
112
+
113
+ // Per-column data state
114
+ const [columnData, setColumnData] = useState<Record<string, BoardColumnData<M>>>(() => {
115
+ const initial: Record<string, BoardColumnData<M>> = {};
116
+ columns.forEach(col => {
117
+ initial[col] = {
118
+ entities: [],
119
+ loading: true,
120
+ hasMore: true,
121
+ error: undefined,
122
+ totalCount: undefined
123
+ };
124
+ });
125
+ return initial;
126
+ });
127
+
128
+ // Track cleanup functions for subscriptions
129
+ const unsubscribersRef = useRef<Record<string, () => void>>({});
130
+
131
+ // Flag to prevent race conditions during cleanup
132
+ const isCleaningUpRef = useRef(false);
133
+
134
+ // Stable keys for dependency comparison
135
+ const columnsKey = useMemo(() => [...columns].sort().join(","), [columns]);
136
+ const filterKey = useMemo(() => JSON.stringify(filterValues), [filterValues]);
137
+
138
+ // Track previous column item counts to detect which column changed
139
+ const prevColumnItemCountsRef = useRef<Record<string, number>>(columnItemCounts);
140
+
141
+ // Version counter to trigger full re-subscription when params change (not just load-more)
142
+ const [subscriptionVersion, setSubscriptionVersion] = useState(0);
143
+
144
+ // Trigger full re-subscription when key params change
145
+ useEffect(() => {
146
+ setSubscriptionVersion(v => v + 1);
147
+ }, [columnsKey, resolvedPath, columnProperty, orderProperty, searchString, filterKey, pageSize]);
148
+
149
+ // Cleanup subscriptions on unmount
150
+ useEffect(() => {
151
+ return () => {
152
+ isCleaningUpRef.current = true;
153
+ Object.values(unsubscribersRef.current).forEach(unsub => unsub?.());
154
+ unsubscribersRef.current = {};
155
+ };
156
+ }, []);
157
+
158
+ // Helper function to subscribe to a single column - uses refs to avoid dependency issues
159
+ const subscribeToColumn = useCallback((column: string, itemCount: number) => {
160
+ // Skip if we're in the middle of cleanup
161
+ if (isCleaningUpRef.current) return;
162
+
163
+ const currentDataSource = dataSourceRef.current;
164
+ const currentCollection = collectionRef.current;
165
+ const currentContext = contextRef.current;
166
+ const currentFilterValues = filterValuesRef.current;
167
+ const currentColumnProperty = columnPropertyRef.current;
168
+ const currentOrderProperty = orderPropertyRef.current;
169
+ const currentSearchString = searchStringRef.current;
170
+ const currentResolvedPath = resolvedPathRef.current;
171
+
172
+ // Build filter for this column
173
+ const columnFilter: FilterValues<string> = {
174
+ ...currentFilterValues,
175
+ [currentColumnProperty]: ["==", column]
176
+ };
177
+
178
+ // Mark column as loading
179
+ setColumnData(prev => ({
180
+ ...prev,
181
+ [column]: {
182
+ ...prev[column],
183
+ loading: true,
184
+ error: undefined
185
+ }
186
+ }));
187
+
188
+ // onUpdate callback
189
+ const onUpdate = async (entities: Entity<M>[]) => {
190
+ // Skip updates if we're cleaning up
191
+ if (isCleaningUpRef.current) return;
192
+
193
+ // When text search is active, the data source returns ALL matching entities
194
+ // regardless of the column filter. We need to filter in memory to only show
195
+ // entities that belong to this specific column.
196
+ let processed = currentSearchString
197
+ ? entities.filter(e => e.values?.[currentColumnProperty] === column)
198
+ : entities;
199
+
200
+ // Apply onFetch callbacks if any
201
+ if (currentCollection.callbacks?.onFetch) {
202
+ try {
203
+ processed = await Promise.all(
204
+ processed.map(entity =>
205
+ currentCollection.callbacks!.onFetch!({
206
+ collection: currentCollection,
207
+ path: currentResolvedPath,
208
+ entity,
209
+ context: currentContext
210
+ })
211
+ )
212
+ );
213
+ } catch (e) {
214
+ console.error("Error in onFetch callback:", e);
215
+ }
216
+ }
217
+
218
+ setColumnData(prev => ({
219
+ ...prev,
220
+ [column]: {
221
+ entities: processed,
222
+ loading: false,
223
+ hasMore: entities.length >= itemCount,
224
+ error: undefined,
225
+ totalCount: prev[column]?.totalCount // Keep existing count
226
+ }
227
+ }));
228
+ };
229
+
230
+ const onError = (error: Error) => {
231
+ // Skip error handling if we're cleaning up
232
+ if (isCleaningUpRef.current) return;
233
+
234
+ console.error(`Error loading column ${column}:`, error);
235
+ setColumnData(prev => ({
236
+ ...prev,
237
+ [column]: {
238
+ ...prev[column],
239
+ entities: [],
240
+ loading: false,
241
+ hasMore: false,
242
+ error
243
+ }
244
+ }));
245
+ };
246
+
247
+ // Set up listener or fetch
248
+ if (currentDataSource.listenCollection) {
249
+ const unsubscribe = currentDataSource.listenCollection<M>({
250
+ path: currentResolvedPath,
251
+ collection: currentCollection,
252
+ onUpdate,
253
+ onError,
254
+ searchString: currentSearchString,
255
+ filter: columnFilter,
256
+ limit: itemCount,
257
+ startAfter: undefined,
258
+ orderBy: currentOrderProperty,
259
+ order: currentOrderProperty ? "asc" : undefined
260
+ });
261
+ unsubscribersRef.current[column] = unsubscribe;
262
+ } else {
263
+ currentDataSource.fetchCollection<M>({
264
+ path: currentResolvedPath,
265
+ collection: currentCollection,
266
+ searchString: currentSearchString,
267
+ filter: columnFilter,
268
+ limit: itemCount,
269
+ startAfter: undefined,
270
+ orderBy: currentOrderProperty,
271
+ order: currentOrderProperty ? "asc" : undefined
272
+ })
273
+ .then(onUpdate)
274
+ .catch(onError);
275
+ }
276
+ }, []); // No dependencies - uses refs for all values
277
+
278
+ // Main effect for all column subscriptions - runs when subscriptionVersion changes (i.e., key params change)
279
+ useEffect(() => {
280
+ // Mark that we're setting up new subscriptions
281
+ isCleaningUpRef.current = false;
282
+
283
+ // Clean up all existing subscriptions synchronously
284
+ const existingUnsubscribers = { ...unsubscribersRef.current };
285
+ unsubscribersRef.current = {};
286
+ Object.values(existingUnsubscribers).forEach(unsub => {
287
+ try {
288
+ unsub?.();
289
+ } catch (e) {
290
+ // Ignore cleanup errors
291
+ }
292
+ });
293
+
294
+ const currentDataSource = dataSourceRef.current;
295
+ const currentCollection = collectionRef.current;
296
+ const currentFilterValues = filterValuesRef.current;
297
+ const currentColumnProperty = columnPropertyRef.current;
298
+ const currentSearchString = searchStringRef.current;
299
+ const currentResolvedPath = resolvedPathRef.current;
300
+ const currentColumns = columns;
301
+ const currentColumnItemCounts = columnItemCounts;
302
+
303
+ // Small delay to ensure Firestore has cleaned up previous listeners
304
+ const timeoutId = setTimeout(() => {
305
+ if (isCleaningUpRef.current) return;
306
+
307
+ currentColumns.forEach(column => {
308
+ const itemCount = currentColumnItemCounts[column] ?? pageSize;
309
+ subscribeToColumn(column, itemCount);
310
+
311
+ // Count query for column (for display in column header)
312
+ if (currentDataSource.countEntities) {
313
+ const columnFilter: FilterValues<string> = {
314
+ ...currentFilterValues,
315
+ [currentColumnProperty]: ["==", column]
316
+ };
317
+ currentDataSource.countEntities({
318
+ path: currentResolvedPath,
319
+ collection: currentCollection,
320
+ filter: columnFilter,
321
+ searchString: currentSearchString
322
+ }).then(count => {
323
+ if (isCleaningUpRef.current) return;
324
+ setColumnData(prev => ({
325
+ ...prev,
326
+ [column]: {
327
+ ...prev[column],
328
+ totalCount: count
329
+ }
330
+ }));
331
+ }).catch(e => {
332
+ console.warn(`Failed to get count for column ${column}:`, e);
333
+ });
334
+ }
335
+ });
336
+
337
+ // Update the ref after subscribing all
338
+ prevColumnItemCountsRef.current = { ...currentColumnItemCounts };
339
+ }, 0);
340
+
341
+ return () => {
342
+ clearTimeout(timeoutId);
343
+ isCleaningUpRef.current = true;
344
+ const unsubscribers = { ...unsubscribersRef.current };
345
+ unsubscribersRef.current = {};
346
+ Object.values(unsubscribers).forEach(unsub => {
347
+ try {
348
+ unsub?.();
349
+ } catch (e) {
350
+ // Ignore cleanup errors
351
+ }
352
+ });
353
+ };
354
+ // eslint-disable-next-line react-hooks/exhaustive-deps
355
+ }, [subscriptionVersion, subscribeToColumn, pageSize]);
356
+
357
+ // Track which subscription version last updated the counts
358
+ const lastProcessedVersionRef = useRef(subscriptionVersion);
359
+
360
+ // Separate effect to handle individual column load-more WITHOUT triggering full re-subscription
361
+ useEffect(() => {
362
+ // If subscriptionVersion changed, the main effect will handle everything
363
+ // Skip this effect to avoid race conditions
364
+ if (subscriptionVersion !== lastProcessedVersionRef.current) {
365
+ lastProcessedVersionRef.current = subscriptionVersion;
366
+ prevColumnItemCountsRef.current = { ...columnItemCounts };
367
+ return;
368
+ }
369
+
370
+ const prevCounts = prevColumnItemCountsRef.current;
371
+
372
+ columns.forEach(column => {
373
+ const prevCount = prevCounts[column] ?? pageSize;
374
+ const newCount = columnItemCounts[column] ?? pageSize;
375
+
376
+ // Only re-subscribe if this specific column's count increased (load more)
377
+ if (newCount > prevCount && !isCleaningUpRef.current) {
378
+ // Unsubscribe only this column
379
+ if (unsubscribersRef.current[column]) {
380
+ try {
381
+ unsubscribersRef.current[column]();
382
+ } catch (e) {
383
+ // Ignore cleanup errors
384
+ }
385
+ delete unsubscribersRef.current[column];
386
+ }
387
+ // Re-subscribe with new limit after a small delay
388
+ setTimeout(() => {
389
+ if (!isCleaningUpRef.current) {
390
+ subscribeToColumn(column, newCount);
391
+ }
392
+ }, 0);
393
+ }
394
+ });
395
+
396
+ // Update the ref
397
+ prevColumnItemCountsRef.current = { ...columnItemCounts };
398
+ }, [columnItemCounts, columns, pageSize, subscribeToColumn, subscriptionVersion]);
399
+
400
+ const loadMoreColumn = useCallback((column: COLUMN) => {
401
+ setColumnItemCounts(prev => ({
402
+ ...prev,
403
+ [column]: (prev[column] ?? pageSize) + pageSize
404
+ }));
405
+ }, [pageSize]);
406
+
407
+ const refreshColumn = useCallback((column: COLUMN) => {
408
+ // Force re-subscribe by resetting to initial count
409
+ setColumnItemCounts(prev => ({
410
+ ...prev,
411
+ [column]: pageSize
412
+ }));
413
+ }, [pageSize]);
414
+
415
+ const refreshAll = useCallback(() => {
416
+ const reset: Record<string, number> = {};
417
+ columns.forEach(col => {
418
+ reset[col] = pageSize;
419
+ });
420
+ setColumnItemCounts(reset);
421
+ }, [columns, pageSize]);
422
+
423
+ // Optimistic update for column counts when moving an item between columns
424
+ const updateColumnCounts = useCallback((sourceColumn: COLUMN, targetColumn: COLUMN) => {
425
+ if (sourceColumn === targetColumn) return;
426
+
427
+ setColumnData(prev => {
428
+ const updated = { ...prev };
429
+
430
+ // Decrease source column count
431
+ if (updated[sourceColumn]?.totalCount !== undefined) {
432
+ updated[sourceColumn] = {
433
+ ...updated[sourceColumn],
434
+ totalCount: Math.max(0, (updated[sourceColumn].totalCount ?? 0) - 1)
435
+ };
436
+ }
437
+
438
+ // Increase target column count
439
+ if (updated[targetColumn]?.totalCount !== undefined) {
440
+ updated[targetColumn] = {
441
+ ...updated[targetColumn],
442
+ totalCount: (updated[targetColumn].totalCount ?? 0) + 1
443
+ };
444
+ }
445
+
446
+ return updated;
447
+ });
448
+ }, []);
449
+
450
+ // Optimistic update for column counts when deleting items
451
+ const decrementColumnCounts = useCallback((columnDeltas: Record<COLUMN, number>) => {
452
+ setColumnData(prev => {
453
+ const updated = { ...prev };
454
+
455
+ for (const [column, delta] of Object.entries(columnDeltas) as [COLUMN, number][]) {
456
+ if (updated[column]?.totalCount !== undefined) {
457
+ updated[column] = {
458
+ ...updated[column],
459
+ totalCount: Math.max(0, (updated[column].totalCount ?? 0) - delta)
460
+ };
461
+ }
462
+ }
463
+
464
+ return updated;
465
+ });
466
+ }, []);
467
+
468
+ // Aggregate loading and error state
469
+ const loading = useMemo(() => {
470
+ return Object.values(columnData).some((col) => col.loading);
471
+ }, [columnData]);
472
+
473
+ const error = useMemo(() => {
474
+ const errors = Object.values(columnData)
475
+ .map((col) => col.error)
476
+ .filter(Boolean);
477
+ return errors[0];
478
+ }, [columnData]);
479
+
480
+ return {
481
+ columnData: columnData as Record<COLUMN, BoardColumnData<M>>,
482
+ loadMoreColumn,
483
+ refreshColumn,
484
+ refreshAll,
485
+ updateColumnCounts,
486
+ decrementColumnCounts,
487
+ loading,
488
+ error
489
+ };
490
+ }
@@ -4,7 +4,8 @@ import { Tooltip, TooltipProps } from "@firecms/ui";
4
4
  export function ErrorTooltip(props: TooltipProps) {
5
5
  return (
6
6
  <Tooltip {...props}
7
- tooltipClassName={"!text-red-500 bg-red-50"}>
7
+ className={props.className}
8
+ tooltipClassName={"!text-red-500 bg-red-50"}>
8
9
  {props.children}
9
10
  </Tooltip>
10
11
  );
@@ -35,10 +35,10 @@ export const DEFAULT_GROUP_NAME = "Views";
35
35
  export const ADMIN_GROUP_NAME = "Admin";
36
36
 
37
37
  export function DefaultHomePage({
38
- additionalActions,
39
- additionalChildrenStart,
40
- additionalChildrenEnd
41
- }: {
38
+ additionalActions,
39
+ additionalChildrenStart,
40
+ additionalChildrenEnd
41
+ }: {
42
42
  additionalActions?: React.ReactNode;
43
43
  additionalChildrenStart?: React.ReactNode;
44
44
  additionalChildrenEnd?: React.ReactNode;
@@ -95,6 +95,10 @@ export function DefaultHomePage({
95
95
  entries: NavigationEntry[];
96
96
  } | null>(null);
97
97
 
98
+ // Flag to prevent useEffect from overwriting local DnD state
99
+ const isDndDirtyRef = useRef(false);
100
+ const dndDirtyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
101
+
98
102
  // Memoize the processed groups to avoid unnecessary recalculations
99
103
  const processedGroups = useMemo(() => {
100
104
  const src = filteredNavigationEntries;
@@ -159,7 +163,12 @@ export function DefaultHomePage({
159
163
  }, [filteredNavigationEntries, performingSearch, groupOrderFromNavController, customizationController.plugins]);
160
164
 
161
165
  // Update state only when processedGroups actually changes
166
+ // Skip update if DnD just made a local change (dirty flag is set)
162
167
  useEffect(() => {
168
+ if (isDndDirtyRef.current) {
169
+ // DnD just updated the state, skip this sync
170
+ return;
171
+ }
163
172
  setAdminGroupData(processedGroups.adminGroupData);
164
173
  setItems(processedGroups.items);
165
174
  }, [processedGroups]);
@@ -171,8 +180,8 @@ export function DefaultHomePage({
171
180
  updater:
172
181
  | { name: string; entries: NavigationEntry[] }[]
173
182
  | ((
174
- prev: { name: string; entries: NavigationEntry[] }[]
175
- ) => { name: string; entries: NavigationEntry[] }[])
183
+ prev: { name: string; entries: NavigationEntry[] }[]
184
+ ) => { name: string; entries: NavigationEntry[] }[])
176
185
  ) => {
177
186
  setItems(updater); // local only
178
187
  };
@@ -180,6 +189,19 @@ export function DefaultHomePage({
180
189
  const persistNavigationGroups = (
181
190
  latest: { name: string; entries: NavigationEntry[] }[]
182
191
  ) => {
192
+ // Set dirty flag to prevent useEffect from overwriting local state
193
+ isDndDirtyRef.current = true;
194
+
195
+ // Clear any existing timeout
196
+ if (dndDirtyTimeoutRef.current) {
197
+ clearTimeout(dndDirtyTimeoutRef.current);
198
+ }
199
+
200
+ // Clear dirty flag after a delay to allow navigation to update
201
+ dndDirtyTimeoutRef.current = setTimeout(() => {
202
+ isDndDirtyRef.current = false;
203
+ }, 1000);
204
+
183
205
  // Map ALL groups including "Views"
184
206
  const draggable: NavigationGroupMapping[] = latest.map((g) => ({
185
207
  name: g.name,
@@ -205,7 +227,7 @@ export function DefaultHomePage({
205
227
  ...(adminGroupData ? [adminGroupData.name] : [])
206
228
  ], [items, adminGroupData]);
207
229
 
208
- const { isGroupCollapsed, toggleGroupCollapsed } = useCollapsedGroups(groupNames);
230
+ const { isGroupCollapsed, toggleGroupCollapsed } = useCollapsedGroups(groupNames, "home");
209
231
 
210
232
 
211
233
  const {
@@ -264,6 +286,7 @@ export function DefaultHomePage({
264
286
  let additionalPluginChildrenStart: React.ReactNode | undefined;
265
287
  let additionalPluginChildrenEnd: React.ReactNode | undefined;
266
288
  let additionalPluginSections: React.ReactNode | undefined;
289
+ let additionalPluginActions: React.ReactNode | undefined;
267
290
 
268
291
  if (customizationController.plugins) {
269
292
  const sectionProps: PluginGenericProps = { context };
@@ -311,6 +334,19 @@ export function DefaultHomePage({
311
334
  ))}
312
335
  </div>
313
336
  );
337
+
338
+ // Collect additionalActions from plugins
339
+ additionalPluginActions = (
340
+ <>
341
+ {customizationController.plugins
342
+ .filter((p) => p.homePage?.additionalActions)
343
+ .map((plugin, i) => (
344
+ <React.Fragment key={`plugin_actions_${i}`}>
345
+ {plugin.homePage!.additionalActions}
346
+ </React.Fragment>
347
+ ))}
348
+ </>
349
+ );
314
350
  }
315
351
 
316
352
  /* ───────────────────────────────────────────────────────────────
@@ -332,9 +368,10 @@ export function DefaultHomePage({
332
368
  className="w-full flex-grow"
333
369
  />
334
370
  {additionalActions}
371
+ {additionalPluginActions}
335
372
  </div>
336
373
 
337
- <FavouritesView hidden={performingSearch}/>
374
+ <FavouritesView hidden={performingSearch} />
338
375
 
339
376
  {additionalChildrenStart}
340
377
  {additionalPluginChildrenStart}
@@ -487,7 +524,7 @@ export function DefaultHomePage({
487
524
 
488
525
  <DragOverlay adjustScale={false} dropAnimation={dropAnimation}>
489
526
  {activeGroupData &&
490
- draggingGroupId === activeGroupData.name ? (
527
+ draggingGroupId === activeGroupData.name ? (
491
528
  <div
492
529
  className="rounded-lg bg-transparent"
493
530
  style={{
@@ -498,7 +535,7 @@ export function DefaultHomePage({
498
535
  <NavigationGroup
499
536
  group={
500
537
  activeGroupData.name ===
501
- DEFAULT_GROUP_NAME
538
+ DEFAULT_GROUP_NAME
502
539
  ? undefined
503
540
  : activeGroupData.name
504
541
  }