@firecms/core 3.0.1 → 3.1.0-canary.24c8270

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