@firecms/core 3.0.1 → 3.1.0-canary.9e89e98

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 +44 -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 +5185 -1561
  39. package/dist/index.es.js.map +1 -1
  40. package/dist/index.umd.js +5179 -1556
  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 +50 -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 +490 -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 +199 -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 +57 -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,713 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ Entity,
4
+ EntityCollection,
5
+ EntityTableController,
6
+ EnumValueConfig,
7
+ FilterValues,
8
+ ResolvedStringProperty,
9
+ SelectionController
10
+ } from "../../types";
11
+ import { Board } from "./Board";
12
+ import { BoardItem, BoardItemViewProps, ColumnLoadingState } from "./board_types";
13
+ import { EntityBoardCard } from "./EntityBoardCard";
14
+ import {
15
+ Button,
16
+ ChipColorKey,
17
+ ChipColorScheme,
18
+ CircularProgress,
19
+ Dialog,
20
+ DialogActions,
21
+ DialogContent,
22
+ getColorSchemeForSeed,
23
+ IconButton,
24
+ RefreshIcon,
25
+ Tooltip,
26
+ Typography
27
+ } from "@firecms/ui";
28
+ import { getPropertyInPath, resolveCollection, resolveEnumValues } from "../../util";
29
+ import {
30
+ saveEntityWithCallbacks,
31
+ useAuthController,
32
+ useCustomizationController,
33
+ useDataSource,
34
+ useFireCMSContext,
35
+ useSideEntityController
36
+ } from "../../hooks";
37
+ import { SaveEntityProps } from "../../types/datasource";
38
+ import { setIn } from "@firecms/formex";
39
+ import { useBoardDataController } from "./useBoardDataController";
40
+
41
+ export type EntityCollectionBoardViewProps<M extends Record<string, any> = any> = {
42
+ collection: EntityCollection<M>;
43
+ tableController: EntityTableController<M>;
44
+ fullPath: string;
45
+ parentCollectionIds?: string[];
46
+ columnProperty: string;
47
+ onEntityClick?: (entity: Entity<M>) => void;
48
+ selectionController?: SelectionController<M>;
49
+ selectionEnabled?: boolean;
50
+ highlightedEntities?: Entity<M>[];
51
+ emptyComponent?: React.ReactNode;
52
+ /** Called when entities are deleted - used for optimistic count updates */
53
+ deletedEntities?: Entity<M>[];
54
+ };
55
+
56
+ /**
57
+ * Kanban board view for displaying entities grouped by a string enum property.
58
+ */
59
+ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
60
+ collection,
61
+ tableController,
62
+ fullPath,
63
+ parentCollectionIds = [],
64
+ columnProperty,
65
+ onEntityClick,
66
+ selectionController,
67
+ selectionEnabled = true,
68
+ highlightedEntities,
69
+ emptyComponent,
70
+ deletedEntities
71
+ }: EntityCollectionBoardViewProps<M>) {
72
+ const authController = useAuthController();
73
+ const customizationController = useCustomizationController();
74
+ const context = useFireCMSContext();
75
+ const dataSource = useDataSource(collection);
76
+ const sideEntityController = useSideEntityController();
77
+ const plugins = customizationController.plugins ?? [];
78
+
79
+ // State for backfill dialog
80
+ const [showBackfillDialog, setShowBackfillDialog] = useState(false);
81
+ const [backfillLoading, setBackfillLoading] = useState(false);
82
+
83
+ const resolvedCollection = useMemo(() => resolveCollection({
84
+ collection,
85
+ path: collection.path,
86
+ propertyConfigs: customizationController.propertyConfigs,
87
+ authController
88
+ }), [collection, customizationController.propertyConfigs, authController]);
89
+
90
+ // Get orderProperty from collection config, but validate it exists as a real property
91
+ const rawOrderProperty = collection.orderProperty;
92
+ const orderProperty = useMemo(() => {
93
+ if (!rawOrderProperty) return undefined;
94
+ // Check if the property actually exists in the resolved collection
95
+ const property = getPropertyInPath(resolvedCollection.properties, rawOrderProperty);
96
+ if (!property) {
97
+ console.warn(`orderProperty "${rawOrderProperty}" is defined but does not exist in the collection properties. Treating as unconfigured.`);
98
+ return undefined;
99
+ }
100
+ return rawOrderProperty;
101
+ }, [rawOrderProperty, resolvedCollection.properties]);
102
+
103
+ // Get columns from the property's enumValues
104
+ const {
105
+ enumColumns,
106
+ columnLabels,
107
+ columnColors
108
+ } = useMemo(() => {
109
+ const property = getPropertyInPath(resolvedCollection.properties, columnProperty);
110
+ if (!property || !("dataType" in property) || property.dataType !== "string") {
111
+ return {
112
+ enumColumns: [] as string[],
113
+ columnLabels: {} as Record<string, string>
114
+ };
115
+ }
116
+ const stringProperty = property as ResolvedStringProperty;
117
+ if (!stringProperty.enumValues) {
118
+ return {
119
+ enumColumns: [] as string[],
120
+ columnLabels: {} as Record<string, string>
121
+ };
122
+ }
123
+ const enumValues = resolveEnumValues(stringProperty.enumValues);
124
+ if (!enumValues) {
125
+ return {
126
+ enumColumns: [] as string[],
127
+ columnLabels: {} as Record<string, string>
128
+ };
129
+ }
130
+ const cols = enumValues.map((ev: EnumValueConfig) => String(ev.id));
131
+ const labels = enumValues.reduce((acc: Record<string, string>, ev: EnumValueConfig) => {
132
+ acc[String(ev.id)] = ev.label;
133
+ return acc;
134
+ }, {});
135
+ const colors = enumValues.reduce((acc: Record<string, ChipColorKey | ChipColorScheme | undefined>, ev: EnumValueConfig) => {
136
+ acc[String(ev.id)] = ev.color ?? getColorSchemeForSeed(String(ev.id));
137
+ return acc;
138
+ }, {});
139
+ return {
140
+ enumColumns: cols,
141
+ columnLabels: labels,
142
+ columnColors: colors
143
+ };
144
+ }, [resolvedCollection, columnProperty]);
145
+
146
+ // Track if user has manually reordered columns in this session
147
+ const [hasUserReordered, setHasUserReordered] = useState(false);
148
+ // Column order is derived from the property's enumValues order
149
+ // Local state tracks session reordering before it's persisted
150
+ const [localColumnsOrder, setLocalColumnsOrder] = useState<string[]>(enumColumns);
151
+
152
+ useEffect(() => {
153
+ if (!hasUserReordered) {
154
+ // Sync with enumColumns when property changes
155
+ setLocalColumnsOrder(enumColumns);
156
+ } else {
157
+ // User has reordered - only add any missing columns
158
+ const missingColumns = enumColumns.filter(c => !localColumnsOrder.includes(c));
159
+ if (missingColumns.length > 0) {
160
+ setLocalColumnsOrder(prev => [...prev, ...missingColumns]);
161
+ }
162
+ }
163
+ }, [enumColumns, hasUserReordered]);
164
+
165
+ const columns = localColumnsOrder;
166
+
167
+ // Use the new per-column data controller
168
+ const boardDataController = useBoardDataController<M>({
169
+ fullPath,
170
+ collection,
171
+ columnProperty,
172
+ columns,
173
+ orderProperty,
174
+ pageSize: 30,
175
+ searchString: tableController.searchString,
176
+ filterValues: tableController.filterValues
177
+ });
178
+
179
+ // Aggregate loading and error state
180
+ const dataLoading = boardDataController.loading;
181
+ const dataLoadingError = boardDataController.error;
182
+
183
+ // Track previously processed deleted entities to avoid double-counting
184
+ const processedDeletedRef = useRef<Set<string>>(new Set());
185
+
186
+ // Optimistic update for column counts when entities are deleted
187
+ useEffect(() => {
188
+ if (!deletedEntities || deletedEntities.length === 0) return;
189
+
190
+ // Calculate column deltas from deleted entities
191
+ const deltas: Record<string, number> = {};
192
+ deletedEntities.forEach(entity => {
193
+ // Skip if we've already processed this entity
194
+ if (processedDeletedRef.current.has(entity.id)) return;
195
+ processedDeletedRef.current.add(entity.id);
196
+
197
+ const col = entity.values?.[columnProperty];
198
+ if (col && typeof col === "string") {
199
+ deltas[col] = (deltas[col] ?? 0) + 1;
200
+ }
201
+ });
202
+
203
+ if (Object.keys(deltas).length > 0) {
204
+ boardDataController.decrementColumnCounts(deltas);
205
+ }
206
+ }, [deletedEntities, columnProperty, boardDataController]);
207
+
208
+ // Build all entities from all columns for operations that need the full list
209
+ const allEntities = useMemo(() => {
210
+ const entities: Entity<M>[] = [];
211
+ columns.forEach(col => {
212
+ const colData = boardDataController.columnData[col];
213
+ if (colData?.entities) {
214
+ entities.push(...colData.entities);
215
+ }
216
+ });
217
+ return entities;
218
+ }, [boardDataController.columnData, columns]);
219
+
220
+ const allowColumnReorder = useMemo(() => {
221
+ return plugins.some(plugin => plugin.collectionView?.onKanbanColumnsReorder);
222
+ }, [plugins]);
223
+
224
+ const handleColumnReorder = useCallback((newColumns: string[]) => {
225
+ setHasUserReordered(true);
226
+ setLocalColumnsOrder(newColumns);
227
+ plugins
228
+ .filter(plugin => plugin.collectionView?.onKanbanColumnsReorder)
229
+ .forEach(plugin => {
230
+ plugin.collectionView!.onKanbanColumnsReorder!({
231
+ fullPath,
232
+ parentCollectionIds,
233
+ collection,
234
+ kanbanColumnProperty: columnProperty,
235
+ newColumnsOrder: newColumns
236
+ });
237
+ });
238
+ }, [plugins, fullPath, parentCollectionIds, collection, columnProperty]);
239
+
240
+ // Collection-level count queries to detect missing order property
241
+ // Just TWO counts: total and ordered (for the entire collection, not per column)
242
+ const [missingOrderCount, setMissingOrderCount] = useState<number>(0);
243
+
244
+ // Use refs for objects that shouldn't trigger re-runs
245
+ const dataSourceRef = useRef(dataSource);
246
+ const collectionRef = useRef(collection);
247
+ dataSourceRef.current = dataSource;
248
+ collectionRef.current = collection;
249
+
250
+ useEffect(() => {
251
+ const currentDataSource = dataSourceRef.current;
252
+ const currentCollection = collectionRef.current;
253
+
254
+ if (!orderProperty || !currentDataSource.countEntities) {
255
+ setMissingOrderCount(0);
256
+ return;
257
+ }
258
+
259
+ // Count 1: Total documents in collection
260
+ // Count 2: Documents with orderProperty != null
261
+ let totalCount = 0;
262
+ let orderedCount = 0;
263
+ let completed = 0;
264
+
265
+ currentDataSource.countEntities({
266
+ path: fullPath,
267
+ collection: currentCollection
268
+ }).then(count => {
269
+ totalCount = count;
270
+ completed++;
271
+ if (completed === 2) {
272
+ setMissingOrderCount(Math.max(0, totalCount - orderedCount));
273
+ }
274
+ }).catch(e => console.warn("Failed to get total count:", e));
275
+
276
+ currentDataSource.countEntities({
277
+ path: fullPath,
278
+ collection: currentCollection,
279
+ filter: { [orderProperty]: ["!=", null] } as FilterValues<string>
280
+ }).then(count => {
281
+ orderedCount = count;
282
+ completed++;
283
+ if (completed === 2) {
284
+ setMissingOrderCount(Math.max(0, totalCount - orderedCount));
285
+ }
286
+ }).catch(e => console.warn("Failed to get ordered count:", e));
287
+ }, [orderProperty, fullPath]); // Only re-run when these primitives change
288
+
289
+ // Check if items need backfill (have no orderProperty values)
290
+ const itemsNeedBackfill = useMemo(() => {
291
+ if (!orderProperty || dataLoading) return false;
292
+ // Use collection-level count detection
293
+ if (missingOrderCount > 0) return true;
294
+ // Fallback to checking loaded entities
295
+ return allEntities.some((entity: Entity<M>) => {
296
+ const orderValue = entity.values?.[orderProperty];
297
+ return orderValue === undefined || orderValue === null;
298
+ });
299
+ }, [allEntities, orderProperty, dataLoading, missingOrderCount]);
300
+
301
+ // Convert entities to board items per column (data already sorted by orderProperty from controller)
302
+ const boardItems: BoardItem<M>[] = useMemo(() => {
303
+ return allEntities.map((entity: Entity<M>) => ({
304
+ id: entity.id,
305
+ entity
306
+ }));
307
+ }, [allEntities]);
308
+
309
+ // Column loading state from the board data controller
310
+ const columnLoadingState: ColumnLoadingState = useMemo(() => {
311
+ const state: ColumnLoadingState = {};
312
+ columns.forEach(col => {
313
+ const colData = boardDataController.columnData[col];
314
+ state[col] = {
315
+ loading: colData?.loading ?? true,
316
+ hasMore: colData?.hasMore ?? false,
317
+ itemCount: colData?.entities?.length ?? 0,
318
+ totalCount: colData?.totalCount
319
+ };
320
+ });
321
+ return state;
322
+ }, [columns, boardDataController.columnData]);
323
+
324
+ const assignColumn = useCallback((item: BoardItem<M>): string => {
325
+ const value = item.entity.values?.[columnProperty];
326
+ if (value && columns.includes(String(value))) return String(value);
327
+ return columns[0] || "";
328
+ }, [columnProperty, columns]);
329
+
330
+ // Calculate new order value using fractional indexing
331
+ const calculateNewOrder = useCallback((
332
+ items: BoardItem<M>[],
333
+ movedItemId: string,
334
+ targetColumn: string
335
+ ): number => {
336
+ // Get items in target column (sorted by order)
337
+ const columnItems = items
338
+ .filter(item => {
339
+ const col = item.entity.values?.[columnProperty];
340
+ return col === targetColumn || (item.id === movedItemId);
341
+ })
342
+ .filter(item => item.id !== movedItemId)
343
+ .sort((a, b) => {
344
+ const orderA = a.entity.values?.[orderProperty!] ?? 0;
345
+ const orderB = b.entity.values?.[orderProperty!] ?? 0;
346
+ return orderA - orderB;
347
+ });
348
+
349
+ // Find the moved item's new position in the column
350
+ const movedItemIndex = items.findIndex(item => item.id === movedItemId);
351
+ const movedItem = items[movedItemIndex];
352
+
353
+ if (!movedItem) return 0;
354
+
355
+ // Find items before and after in the target column
356
+ let prevOrder: number | null = null;
357
+ let nextOrder: number | null = null;
358
+
359
+ // Simple approach: find the item at the new position
360
+ const newColumnItems = items.filter(item => {
361
+ if (item.id === movedItemId) return true;
362
+ const col = item.entity.values?.[columnProperty];
363
+ return col === targetColumn;
364
+ });
365
+
366
+ const newIndex = newColumnItems.findIndex(item => item.id === movedItemId);
367
+
368
+ if (newIndex > 0) {
369
+ const prevItem = newColumnItems[newIndex - 1];
370
+ prevOrder = prevItem?.entity.values?.[orderProperty!] ?? null;
371
+ }
372
+ if (newIndex < newColumnItems.length - 1) {
373
+ const nextItem = newColumnItems[newIndex + 1];
374
+ nextOrder = nextItem?.entity.values?.[orderProperty!] ?? null;
375
+ }
376
+
377
+ // Calculate new order using fractional indexing
378
+ if (prevOrder !== null && nextOrder !== null) {
379
+ return (prevOrder + nextOrder) / 2;
380
+ } else if (prevOrder !== null) {
381
+ return prevOrder + 1;
382
+ } else if (nextOrder !== null) {
383
+ return nextOrder - 1;
384
+ }
385
+ return 0;
386
+ }, [columnProperty, orderProperty]);
387
+
388
+ // Handle item reorder and column changes
389
+ const handleItemsReorder = useCallback(async (
390
+ items: BoardItem<M>[],
391
+ moveInfo?: { itemId: string; sourceColumn: string; targetColumn: string; }
392
+ ) => {
393
+ const entity = items.find(item => item.id === moveInfo?.itemId)?.entity;
394
+ if (!entity) return;
395
+
396
+ const isColumnChange = moveInfo && moveInfo.sourceColumn !== moveInfo.targetColumn;
397
+
398
+ // If no orderProperty and not a column change, nothing to do
399
+ if (!orderProperty && !isColumnChange) return;
400
+
401
+ // Optimistic update: update column counts immediately when moving between columns
402
+ if (isColumnChange) {
403
+ boardDataController.updateColumnCounts(moveInfo.sourceColumn, moveInfo.targetColumn);
404
+ }
405
+
406
+ // Build updated values
407
+ let updatedValues = { ...entity.values };
408
+
409
+ // Calculate and set new order value (only if orderProperty is configured)
410
+ if (orderProperty) {
411
+ const newOrder = calculateNewOrder(items, moveInfo?.itemId ?? "", moveInfo?.targetColumn ?? "");
412
+ updatedValues = setIn(updatedValues, orderProperty, newOrder);
413
+ }
414
+
415
+ // Update column if it changed
416
+ if (isColumnChange) {
417
+ updatedValues = setIn(updatedValues, columnProperty, moveInfo.targetColumn);
418
+ }
419
+
420
+ const saveProps: SaveEntityProps = {
421
+ path: entity.path,
422
+ entityId: entity.id,
423
+ values: updatedValues as M,
424
+ previousValues: entity.values,
425
+ collection,
426
+ status: "existing"
427
+ };
428
+
429
+ try {
430
+ await saveEntityWithCallbacks({
431
+ ...saveProps,
432
+ collection,
433
+ dataSource,
434
+ context,
435
+ onSaveSuccess: () => {
436
+ },
437
+ onSaveFailure: (e: Error) => console.error("Failed to save entity after reorder:", e),
438
+ onPreSaveHookError: (e: Error) => console.error("Pre-save hook error:", e)
439
+ });
440
+ } catch (e) {
441
+ console.error("Error saving entity:", e);
442
+ }
443
+ }, [collection, columnProperty, orderProperty, context, dataSource, calculateNewOrder, boardDataController]);
444
+
445
+ // Backfill order values for all entities
446
+ const handleBackfill = useCallback(async () => {
447
+ console.log("handleBackfill called", { orderProperty });
448
+ if (!orderProperty) {
449
+ console.log("No orderProperty, returning");
450
+ return;
451
+ }
452
+ setBackfillLoading(true);
453
+
454
+ try {
455
+ // Fetch ALL documents from collection (not relying on loaded entities)
456
+ console.log("Fetching all documents from collection...");
457
+ const allDocs = await dataSource.fetchCollection<M>({
458
+ path: fullPath,
459
+ collection,
460
+ limit: 10000 // Fetch all
461
+ });
462
+ console.log(`Fetched ${allDocs.length} documents`);
463
+
464
+ // Find entities missing order property
465
+ const entitiesToUpdate = allDocs.filter((entity: Entity<M>) => {
466
+ const orderValue = entity.values?.[orderProperty];
467
+ return orderValue === undefined || orderValue === null;
468
+ });
469
+ console.log(`${entitiesToUpdate.length} entities need order values`);
470
+
471
+ // Assign sequential order values
472
+ const updates: Promise<void>[] = [];
473
+ entitiesToUpdate.forEach((entity: Entity<M>, index: number) => {
474
+ console.log(`Updating entity ${entity.id} with order ${index}`);
475
+ const updatedValues = setIn({ ...entity.values }, orderProperty, index);
476
+
477
+ const saveProps: SaveEntityProps = {
478
+ path: entity.path,
479
+ entityId: entity.id,
480
+ values: updatedValues as M,
481
+ previousValues: entity.values,
482
+ collection,
483
+ status: "existing"
484
+ };
485
+
486
+ updates.push(
487
+ saveEntityWithCallbacks({
488
+ ...saveProps,
489
+ collection,
490
+ dataSource,
491
+ context,
492
+ onSaveSuccess: () => {
493
+ console.log(`Saved entity ${entity.id}`);
494
+ },
495
+ onSaveFailure: (e) => console.error("Backfill save failed:", e),
496
+ onPreSaveHookError: (e) => console.error("Backfill pre-save error:", e)
497
+ }).then(() => {
498
+ })
499
+ );
500
+ });
501
+
502
+ console.log(`Total updates to run: ${updates.length}`);
503
+ await Promise.all(updates);
504
+ console.log("All updates complete");
505
+ setShowBackfillDialog(false);
506
+
507
+ // Reset missing count to hide banner
508
+ setMissingOrderCount(0);
509
+
510
+ // Refresh the board data
511
+ boardDataController.refreshAll();
512
+ } catch (e) {
513
+ console.error("Backfill error:", e);
514
+ } finally {
515
+ setBackfillLoading(false);
516
+ }
517
+ }, [orderProperty, fullPath, collection, dataSource, context, boardDataController]);
518
+
519
+ const handleEntityClick = useCallback((entity: Entity<M>) => {
520
+ onEntityClick?.(entity);
521
+ }, [onEntityClick]);
522
+
523
+ const handleSelectionChange = useCallback((entity: Entity<M>, selected: boolean) => {
524
+ selectionController?.toggleEntitySelection(entity, selected);
525
+ }, [selectionController]);
526
+
527
+ const isEntitySelected = useCallback((entity: Entity<M>) => {
528
+ return selectionController?.isEntitySelected(entity) ?? false;
529
+ }, [selectionController]);
530
+
531
+ const ItemComponent = useCallback((props: BoardItemViewProps<M>) => {
532
+ return (
533
+ <EntityBoardCard
534
+ {...props}
535
+ collection={collection}
536
+ onClick={handleEntityClick}
537
+ selected={isEntitySelected(props.item.entity)}
538
+ onSelectionChange={handleSelectionChange}
539
+ selectionEnabled={selectionEnabled}
540
+ />
541
+ );
542
+ }, [collection, handleEntityClick, isEntitySelected, handleSelectionChange, selectionEnabled]);
543
+
544
+ // Get KanbanSetupComponent from plugins
545
+ const KanbanSetupComponent = useMemo(() => {
546
+ for (const plugin of plugins) {
547
+ if (plugin.collectionView?.KanbanSetupComponent) {
548
+ return plugin.collectionView.KanbanSetupComponent;
549
+ }
550
+ }
551
+ return null;
552
+ }, [plugins]);
553
+
554
+ // Get AddKanbanColumnComponent from plugins
555
+ const AddKanbanColumnComponent = useMemo(() => {
556
+ for (const plugin of plugins) {
557
+ if (plugin.collectionView?.AddKanbanColumnComponent) {
558
+ return plugin.collectionView.AddKanbanColumnComponent;
559
+ }
560
+ }
561
+ return null;
562
+ }, [plugins]);
563
+
564
+ // Check for loading error
565
+ const hasError = Boolean(dataLoadingError);
566
+ const errorMessage = dataLoadingError?.message || "";
567
+ const indexUrl = errorMessage.match(/https:\/\/console\.firebase\.google\.com[^\s]+/)?.[0];
568
+
569
+ // Error: no enum properties available for Kanban columns
570
+ if (!columnProperty || enumColumns.length === 0) {
571
+ return (
572
+ <div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
573
+ <Typography variant="h6">
574
+ Kanban view is not available
575
+ </Typography>
576
+ <Typography variant="body2" color="secondary" className="text-center max-w-md">
577
+ Kanban view requires a string property with enum values to group entities into columns.
578
+ Please add an enum property to your collection schema to use this view.
579
+ </Typography>
580
+ {KanbanSetupComponent && (
581
+ <KanbanSetupComponent
582
+ collection={collection}
583
+ fullPath={fullPath}
584
+ parentCollectionIds={parentCollectionIds}
585
+ />
586
+ )}
587
+ </div>
588
+ );
589
+ }
590
+
591
+ // Note: Empty state is not shown for Kanban view - we show the board with empty columns instead
592
+ // The emptyComponent is handled per-column in BoardColumn
593
+
594
+ // No columns
595
+ if (columns.length === 0) {
596
+ return (
597
+ <div className="flex-1 flex items-center justify-center p-8">
598
+ <Typography variant="label" color="secondary">
599
+ No enum values configured for property "{columnProperty}"
600
+ </Typography>
601
+ </div>
602
+ );
603
+ }
604
+
605
+ return (
606
+ <div className="flex-1 flex flex-col overflow-hidden">
607
+ {/* Error banner - only show when no data loaded */}
608
+ {hasError && allEntities.length === 0 && (
609
+ <div
610
+ className="flex items-center gap-4 px-4 py-3 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
611
+ <Typography variant="body2" className="text-red-700 dark:text-red-300 flex-1">
612
+ <strong>Error:</strong>{" "}
613
+ {indexUrl
614
+ ? "A Firestore index is required for this query."
615
+ : errorMessage}
616
+ </Typography>
617
+ <Tooltip title="Refresh data">
618
+ <IconButton
619
+ size="small"
620
+ onClick={() => boardDataController.refreshAll()}
621
+ >
622
+ <RefreshIcon size="small" />
623
+ </IconButton>
624
+ </Tooltip>
625
+ {indexUrl && (
626
+ <Button
627
+ size="small"
628
+ variant="outlined"
629
+ color="error"
630
+ onClick={() => window.open(indexUrl, "_blank")}
631
+ >
632
+ Create Index
633
+ </Button>
634
+ )}
635
+ </div>
636
+ )}
637
+
638
+ {/* Backfill info bar - non-blocking */}
639
+ {itemsNeedBackfill && !dataLoading && (
640
+ <div
641
+ className="flex items-center justify-between gap-4 px-4 py-2 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
642
+ <Typography variant="body2" color="secondary">
643
+ Some items don't have order values. Initialize to enable drag-and-drop reordering.
644
+ </Typography>
645
+ <Button
646
+ size="small"
647
+ variant="text"
648
+ onClick={() => setShowBackfillDialog(true)}
649
+ >
650
+ Initialize Order
651
+ </Button>
652
+ </div>
653
+ )}
654
+
655
+ {/* Main board */}
656
+ <div className="flex-1 overflow-auto no-scrollbar">
657
+ <Board
658
+ data={boardItems}
659
+ columns={columns}
660
+ columnLabels={columnLabels}
661
+ columnColors={columnColors}
662
+ assignColumn={assignColumn}
663
+ allowColumnReorder={allowColumnReorder}
664
+ onColumnReorder={handleColumnReorder}
665
+ onItemsReorder={handleItemsReorder}
666
+ ItemComponent={ItemComponent}
667
+ columnLoadingState={columnLoadingState}
668
+ onLoadMoreColumn={(column) => boardDataController.loadMoreColumn(column)}
669
+ onAddItemToColumn={(column) => {
670
+ sideEntityController.open({
671
+ path: fullPath,
672
+ collection,
673
+ entityId: undefined,
674
+ updateUrl: true,
675
+ formProps: {
676
+ initialDirtyValues: {
677
+ [columnProperty]: column
678
+ } as Partial<M>
679
+ }
680
+ });
681
+ }}
682
+ AddColumnComponent={AddKanbanColumnComponent && (
683
+ <AddKanbanColumnComponent
684
+ collection={collection}
685
+ fullPath={fullPath}
686
+ parentCollectionIds={parentCollectionIds}
687
+ columnProperty={columnProperty}
688
+ />
689
+ )}
690
+ />
691
+ </div>
692
+
693
+ {/* Backfill dialog */}
694
+ <Dialog open={showBackfillDialog} onOpenChange={setShowBackfillDialog}>
695
+ <DialogContent>
696
+ <Typography variant="h6" className="mb-4">Initialize Kanban Order</Typography>
697
+ <Typography variant="body2">
698
+ This will assign sequential order values to all items that don't have one.
699
+ Items will maintain their current order within each column.
700
+ </Typography>
701
+ </DialogContent>
702
+ <DialogActions>
703
+ <Button variant="text" onClick={() => setShowBackfillDialog(false)} disabled={backfillLoading}>
704
+ Cancel
705
+ </Button>
706
+ <Button onClick={handleBackfill} disabled={backfillLoading}>
707
+ {backfillLoading ? <CircularProgress size="smallest" /> : "Initialize"}
708
+ </Button>
709
+ </DialogActions>
710
+ </Dialog>
711
+ </div>
712
+ );
713
+ }