@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.
- package/README.md +1 -1
- package/dist/components/AIIcon.d.ts +16 -0
- package/dist/components/EntityCollectionTable/EntityCollectionRowActions.d.ts +7 -1
- package/dist/components/EntityCollectionTable/EntityCollectionTable.d.ts +1 -1
- package/dist/components/EntityCollectionTable/EntityCollectionTableProps.d.ts +14 -0
- package/dist/components/EntityCollectionTable/PropertyTableCell.d.ts +6 -0
- package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +5 -4
- package/dist/components/EntityCollectionTable/internal/EntityTableCell.d.ts +6 -0
- package/dist/components/EntityCollectionView/Board.d.ts +2 -0
- package/dist/components/EntityCollectionView/BoardColumn.d.ts +42 -0
- package/dist/components/EntityCollectionView/BoardColumnTitle.d.ts +9 -0
- package/dist/components/EntityCollectionView/BoardSortableList.d.ts +14 -0
- package/dist/components/EntityCollectionView/EntityBoardCard.d.ts +26 -0
- package/dist/components/EntityCollectionView/EntityCard.d.ts +19 -0
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +20 -0
- package/dist/components/EntityCollectionView/EntityCollectionCardView.d.ts +31 -0
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +2 -2
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +7 -3
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +14 -0
- package/dist/components/EntityCollectionView/ViewModeToggle.d.ts +49 -0
- package/dist/components/EntityCollectionView/board_types.d.ts +105 -0
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +60 -0
- package/dist/components/SelectableTable/SelectableTable.d.ts +5 -1
- package/dist/components/SelectableTable/filters/DateTimeFilterField.d.ts +2 -1
- package/dist/components/VirtualTable/VirtualTableCell.d.ts +6 -0
- package/dist/components/VirtualTable/VirtualTableHeader.d.ts +2 -0
- package/dist/components/VirtualTable/VirtualTableHeaderRow.d.ts +1 -1
- package/dist/components/VirtualTable/VirtualTableProps.d.ts +11 -0
- package/dist/components/VirtualTable/fields/VirtualTableDateField.d.ts +1 -0
- package/dist/components/VirtualTable/types.d.ts +2 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/contexts/index.d.ts +10 -0
- package/dist/core/DrawerNavigationGroup.d.ts +45 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/form/validation.d.ts +3 -2
- package/dist/hooks/useBreadcrumbsController.d.ts +16 -0
- package/dist/hooks/useCollapsedGroups.d.ts +4 -1
- package/dist/index.es.js +5239 -1590
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +5233 -1585
- package/dist/index.umd.js.map +1 -1
- package/dist/preview/PropertyPreviewProps.d.ts +5 -0
- package/dist/preview/components/DatePreview.d.ts +13 -3
- package/dist/preview/components/ImagePreview.d.ts +5 -1
- package/dist/preview/components/StorageThumbnail.d.ts +2 -1
- package/dist/preview/components/UrlComponentPreview.d.ts +2 -1
- package/dist/preview/property_previews/ArrayOfStorageComponentsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
- package/dist/preview/property_previews/SkeletonPropertyComponent.d.ts +1 -1
- package/dist/types/collections.d.ts +42 -2
- package/dist/types/datasource.d.ts +0 -1
- package/dist/types/plugins.d.ts +46 -1
- package/dist/types/properties.d.ts +259 -4
- package/dist/util/__tests__/conditions.test.d.ts +1 -0
- package/dist/util/__tests__/objects.test.d.ts +1 -0
- package/dist/util/conditions.d.ts +26 -0
- package/dist/util/entities.d.ts +1 -2
- package/dist/util/index.d.ts +2 -1
- package/dist/util/property_utils.d.ts +2 -1
- package/dist/util/resolutions.d.ts +1 -1
- package/package.json +10 -7
- package/src/app/Scaffold.tsx +14 -15
- package/src/components/AIIcon.tsx +39 -0
- package/src/components/ArrayContainer.tsx +1 -4
- package/src/components/ClearFilterSortButton.tsx +19 -16
- package/src/components/ConfirmationDialog.tsx +0 -2
- package/src/components/DeleteEntityDialog.tsx +2 -4
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +74 -41
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +130 -79
- package/src/components/EntityCollectionTable/EntityCollectionTableProps.tsx +121 -104
- package/src/components/EntityCollectionTable/PropertyTableCell.tsx +132 -103
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +20 -42
- package/src/components/EntityCollectionTable/internal/EntityTableCell.tsx +90 -49
- package/src/components/EntityCollectionView/Board.tsx +324 -0
- package/src/components/EntityCollectionView/BoardColumn.tsx +158 -0
- package/src/components/EntityCollectionView/BoardColumnTitle.tsx +45 -0
- package/src/components/EntityCollectionView/BoardSortableList.tsx +172 -0
- package/src/components/EntityCollectionView/EntityBoardCard.tsx +212 -0
- package/src/components/EntityCollectionView/EntityCard.tsx +231 -0
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +713 -0
- package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +244 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +485 -203
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +31 -19
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +84 -15
- package/src/components/EntityCollectionView/FiltersDialog.tsx +249 -0
- package/src/components/EntityCollectionView/ViewModeToggle.tsx +202 -0
- package/src/components/EntityCollectionView/board_types.ts +113 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +490 -0
- package/src/components/ErrorTooltip.tsx +2 -1
- package/src/components/HomePage/DefaultHomePage.tsx +47 -10
- package/src/components/HomePage/HomePageDnD.tsx +56 -41
- package/src/components/HomePage/NavigationCard.tsx +20 -18
- package/src/components/HomePage/NavigationGroup.tsx +17 -16
- package/src/components/HomePage/RenameGroupDialog.tsx +0 -2
- package/src/components/HomePage/SmallNavigationCard.tsx +10 -9
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +3 -10
- package/src/components/ReferenceWidget.tsx +2 -4
- package/src/components/SelectableTable/SelectableTable.tsx +75 -67
- package/src/components/SelectableTable/filters/BooleanFilterField.tsx +7 -6
- package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +39 -40
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +38 -38
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +49 -58
- package/src/components/UnsavedChangesDialog.tsx +0 -2
- package/src/components/UserDisplay.tsx +4 -4
- package/src/components/VirtualTable/VirtualTable.tsx +170 -19
- package/src/components/VirtualTable/VirtualTableCell.tsx +18 -2
- package/src/components/VirtualTable/VirtualTableHeader.tsx +20 -11
- package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +158 -42
- package/src/components/VirtualTable/VirtualTableProps.tsx +14 -1
- package/src/components/VirtualTable/VirtualTableRow.tsx +1 -1
- package/src/components/VirtualTable/fields/VirtualTableDateField.tsx +3 -0
- package/src/components/VirtualTable/fields/VirtualTableSelect.tsx +17 -4
- package/src/components/VirtualTable/types.tsx +2 -0
- package/src/components/common/useColumnsIds.tsx +95 -3
- package/src/components/index.tsx +4 -0
- package/src/contexts/BreacrumbsContext.tsx +15 -8
- package/src/contexts/index.ts +10 -0
- package/src/core/DefaultAppBar.tsx +39 -26
- package/src/core/DefaultDrawer.tsx +42 -56
- package/src/core/DrawerNavigationGroup.tsx +118 -0
- package/src/core/DrawerNavigationItem.tsx +4 -3
- package/src/core/EntityEditView.tsx +41 -43
- package/src/core/SideDialogs.tsx +4 -2
- package/src/core/index.tsx +1 -0
- package/src/form/PropertyFieldBinding.tsx +58 -43
- package/src/form/components/StorageItemPreview.tsx +2 -1
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +0 -1
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +17 -16
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +0 -1
- package/src/form/field_bindings/MapFieldBinding.tsx +69 -67
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +21 -17
- package/src/form/field_bindings/TextFieldBinding.tsx +71 -35
- package/src/form/validation.ts +245 -160
- package/src/hooks/useBreadcrumbsController.tsx +18 -0
- package/src/hooks/useBuildNavigationController.tsx +42 -19
- package/src/hooks/useCollapsedGroups.ts +12 -4
- package/src/internal/useBuildDataSource.ts +69 -34
- package/src/internal/useBuildSideDialogsController.tsx +11 -8
- package/src/internal/useBuildSideEntityController.tsx +2 -4
- package/src/internal/useRestoreScroll.tsx +26 -14
- package/src/preview/PropertyPreview.tsx +40 -32
- package/src/preview/PropertyPreviewProps.tsx +6 -0
- package/src/preview/components/DatePreview.tsx +72 -4
- package/src/preview/components/EmptyValue.tsx +1 -1
- package/src/preview/components/ImagePreview.tsx +37 -21
- package/src/preview/components/StorageThumbnail.tsx +16 -12
- package/src/preview/components/UrlComponentPreview.tsx +28 -25
- package/src/preview/property_previews/ArrayOfStorageComponentsPreview.tsx +9 -7
- package/src/preview/property_previews/ArrayOfStringsPreview.tsx +11 -9
- package/src/preview/property_previews/ArrayPropertyPreview.tsx +26 -24
- package/src/preview/property_previews/SkeletonPropertyComponent.tsx +61 -56
- package/src/routes/CustomCMSRoute.tsx +1 -0
- package/src/routes/FireCMSRoute.tsx +26 -13
- package/src/types/collections.ts +48 -3
- package/src/types/datasource.ts +54 -56
- package/src/types/plugins.tsx +51 -1
- package/src/types/properties.ts +347 -27
- package/src/util/__tests__/conditions.test.ts +506 -0
- package/src/util/__tests__/objects.test.ts +196 -0
- package/src/util/callbacks.ts +6 -3
- package/src/util/collections.ts +51 -6
- package/src/util/conditions.ts +339 -0
- package/src/util/entities.ts +28 -29
- package/src/util/entity_cache.ts +2 -1
- package/src/util/index.ts +2 -1
- package/src/util/objects.ts +31 -13
- package/src/util/{references.ts → previews.ts} +14 -0
- package/src/util/property_utils.tsx +36 -10
- package/src/util/resolutions.ts +57 -55
- /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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
+
DEFAULT_GROUP_NAME
|
|
502
539
|
? undefined
|
|
503
540
|
: activeGroupData.name
|
|
504
541
|
}
|