@firecms/core 3.1.0-canary.1df3b2c → 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 (50) hide show
  1. package/dist/components/EntityCollectionTable/internal/popup_field/useDraggable.d.ts +2 -2
  2. package/dist/components/EntityCollectionView/ViewModeToggle.d.ts +5 -10
  3. package/dist/components/ErrorBoundary.d.ts +1 -1
  4. package/dist/components/VirtualTable/VirtualTableHeader.d.ts +1 -1
  5. package/dist/form/components/ErrorFocus.d.ts +1 -1
  6. package/dist/index.es.js +302 -227
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +300 -225
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/internal/useRestoreScroll.d.ts +1 -1
  11. package/dist/types/analytics.d.ts +1 -1
  12. package/dist/types/collections.d.ts +8 -0
  13. package/dist/types/plugins.d.ts +16 -0
  14. package/dist/util/entities.d.ts +1 -1
  15. package/dist/util/resolutions.d.ts +2 -2
  16. package/package.json +9 -9
  17. package/src/components/EntityCollectionTable/internal/EntityTableCellActions.tsx +1 -1
  18. package/src/components/EntityCollectionTable/internal/popup_field/useDraggable.tsx +11 -11
  19. package/src/components/EntityCollectionView/EntityBoardCard.tsx +1 -1
  20. package/src/components/EntityCollectionView/EntityCard.tsx +4 -0
  21. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +23 -3
  22. package/src/components/EntityCollectionView/EntityCollectionView.tsx +50 -16
  23. package/src/components/EntityCollectionView/ViewModeToggle.tsx +27 -30
  24. package/src/components/VirtualTable/VirtualTable.tsx +116 -113
  25. package/src/components/VirtualTable/VirtualTableHeader.tsx +42 -42
  26. package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +1 -1
  27. package/src/components/VirtualTable/fields/VirtualTableSelect.tsx +3 -3
  28. package/src/components/common/useDataSourceTableController.tsx +21 -4
  29. package/src/core/DefaultAppBar.tsx +1 -1
  30. package/src/core/EntityEditView.tsx +1 -1
  31. package/src/core/EntitySidePanel.tsx +28 -26
  32. package/src/core/field_configs.tsx +14 -9
  33. package/src/form/EntityForm.tsx +69 -60
  34. package/src/form/PropertyFieldBinding.tsx +3 -3
  35. package/src/form/components/ErrorFocus.tsx +3 -3
  36. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +1 -1
  37. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +83 -83
  38. package/src/hooks/useBuildNavigationController.tsx +29 -9
  39. package/src/hooks/useValidateAuthenticator.tsx +1 -1
  40. package/src/internal/useBuildDataSource.ts +1 -2
  41. package/src/internal/useBuildSideEntityController.tsx +22 -20
  42. package/src/preview/PropertyPreview.tsx +1 -0
  43. package/src/types/analytics.ts +10 -0
  44. package/src/types/collections.ts +9 -0
  45. package/src/types/plugins.tsx +18 -0
  46. package/src/util/entities.ts +1 -1
  47. package/src/util/join_collections.ts +10 -8
  48. package/src/util/previews.ts +2 -2
  49. package/src/util/property_utils.tsx +1 -1
  50. package/src/util/resolutions.ts +5 -3
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  export declare function useRestoreScroll(): {
3
- containerRef: React.RefObject<HTMLDivElement>;
3
+ containerRef: React.RefObject<HTMLDivElement | null>;
4
4
  scroll: number;
5
5
  direction: "up" | "down";
6
6
  };
@@ -1 +1 @@
1
- export type CMSAnalyticsEvent = "entity_click" | "entity_click_from_reference" | "reference_selection_clear" | "reference_selection_toggle" | "reference_selected_single" | "reference_selection_new_entity" | "edit_entity_clicked" | "entity_edited" | "new_entity_click" | "new_entity_saved" | "copy_entity_click" | "entity_copied" | "single_delete_dialog_open" | "multiple_delete_dialog_open" | "single_entity_deleted" | "multiple_entities_deleted" | "drawer_navigate_to_home" | "drawer_navigate_to_collection" | "drawer_navigate_to_view" | "home_navigate_to_collection" | "home_favorite_navigate_to_collection" | "home_navigate_to_view" | "home_navigate_to_admin_view" | "home_favorite_navigate_to_view" | "home_move_card" | "home_move_group" | "home_drop_new_group" | "collection_inline_editing" | "unmapped_event";
1
+ export type CMSAnalyticsEvent = "entity_click" | "entity_click_from_reference" | "reference_selection_clear" | "reference_selection_toggle" | "reference_selected_single" | "reference_selection_new_entity" | "edit_entity_clicked" | "entity_edited" | "new_entity_click" | "new_entity_saved" | "copy_entity_click" | "entity_copied" | "single_delete_dialog_open" | "multiple_delete_dialog_open" | "single_entity_deleted" | "multiple_entities_deleted" | "drawer_navigate_to_home" | "drawer_navigate_to_collection" | "drawer_navigate_to_view" | "home_navigate_to_collection" | "home_favorite_navigate_to_collection" | "home_navigate_to_view" | "home_navigate_to_admin_view" | "home_favorite_navigate_to_view" | "home_move_card" | "home_move_group" | "home_drop_new_group" | "collection_inline_editing" | "view_mode_changed" | "kanban_card_moved" | "kanban_column_reorder" | "kanban_property_changed" | "kanban_new_entity_in_column" | "kanban_backfill_order" | "card_view_entity_click" | "unmapped_event";
@@ -326,6 +326,14 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
326
326
  * Defaults to "table".
327
327
  */
328
328
  defaultViewMode?: ViewMode;
329
+ /**
330
+ * Which view modes are available for this collection.
331
+ * Possible values: "table", "cards", "kanban".
332
+ * Defaults to all three: ["table", "cards", "kanban"].
333
+ * Note: "kanban" will only be available if the collection has at least
334
+ * one string property with enumValues defined, regardless of this setting.
335
+ */
336
+ enabledViews?: ViewMode[];
329
337
  /**
330
338
  * Configuration for Kanban board view mode.
331
339
  * When set, the Kanban view mode becomes available.
@@ -91,6 +91,18 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
91
91
  onNavigationEntriesUpdate?: (entries: NavigationGroupMapping[]) => void;
92
92
  };
93
93
  collectionView?: {
94
+ /**
95
+ * Custom component to render when a collection loading error occurs.
96
+ * If provided, this replaces the default error view in all collection view modes
97
+ * (table, card, kanban).
98
+ * Return `null` from the component to fall back to the default error view.
99
+ */
100
+ CollectionError?: React.ComponentType<{
101
+ path: string;
102
+ collection: EC;
103
+ parentCollectionIds?: string[];
104
+ error: Error;
105
+ }>;
94
106
  /**
95
107
  * Use this component to add custom actions to the entity collections
96
108
  * toolbar.
@@ -194,6 +206,10 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
194
206
  * Add custom actions to the top of the form
195
207
  */
196
208
  ActionsTop?: React.ComponentType<PluginFormActionProps<any, EC>>;
209
+ /**
210
+ * Add custom content above the entity title in the form view
211
+ */
212
+ BeforeTitle?: React.ComponentType<PluginFormActionProps<any, EC>>;
197
213
  fieldBuilder?: <T extends CMSType = CMSType>(props: PluginFieldBuilderParams<T, any, EC>) => React.ComponentType<FieldProps<T>> | null;
198
214
  fieldBuilderEnabled?: <T extends CMSType = CMSType>(props: PluginFieldBuilderParams<T>) => boolean;
199
215
  };
@@ -1,7 +1,7 @@
1
1
  import { CMSType, DataType, Entity, EntityReference, EntityStatus, EntityValues, PropertiesOrBuilders, Property, PropertyBuilder, PropertyOrBuilder, ResolvedProperties, ResolvedProperty } from "../types";
2
2
  export declare function isReadOnly(property: Property<any> | ResolvedProperty<any>): boolean;
3
3
  export declare function isHidden(property: Property | ResolvedProperty): boolean;
4
- export declare function isPropertyBuilder<T extends CMSType, M extends Record<string, any>>(propertyOrBuilder?: PropertyOrBuilder<T, M> | Property<T> | ResolvedProperty<T>): propertyOrBuilder is PropertyBuilder<T, M>;
4
+ export declare function isPropertyBuilder<T extends CMSType = CMSType, M extends Record<string, any> = any>(propertyOrBuilder?: PropertyOrBuilder<T, M> | Property | ResolvedProperty): propertyOrBuilder is PropertyBuilder<T, M>;
5
5
  export declare function getDefaultValuesFor<M extends Record<string, any>>(properties: PropertiesOrBuilders<M> | ResolvedProperties<M>): Partial<EntityValues<M>>;
6
6
  export declare function getDefaultValueFor(property?: PropertyOrBuilder): {} | null | undefined;
7
7
  export declare function getDefaultValueForDataType(dataType: DataType): {} | null;
@@ -1,4 +1,4 @@
1
- import { ArrayProperty, AuthController, CMSType, CustomizationController, EntityAction, EntityCollection, EntityCustomView, EntityValues, EnumValueConfig, EnumValues, NumberProperty, PropertiesOrBuilders, PropertyConfig, PropertyOrBuilder, ResolvedArrayProperty, ResolvedEntityCollection, ResolvedNumberProperty, ResolvedProperties, ResolvedProperty, ResolvedStringProperty, StringProperty, UserConfigurationPersistence } from "../types";
1
+ import { ArrayProperty, AuthController, CMSType, CustomizationController, EntityAction, EntityCollection, EntityCustomView, EntityValues, EnumValueConfig, EnumValues, NumberProperty, PropertiesOrBuilders, Property, PropertyConfig, PropertyOrBuilder, ResolvedArrayProperty, ResolvedEntityCollection, ResolvedNumberProperty, ResolvedProperties, ResolvedProperty, ResolvedStringProperty, StringProperty, UserConfigurationPersistence } from "../types";
2
2
  export declare const resolveCollection: <M extends Record<string, any>>({ collection, path, entityId, values, previousValues, userConfigPersistence, propertyConfigs, ignoreMissingFields, authController }: {
3
3
  collection: EntityCollection<M> | ResolvedEntityCollection<M>;
4
4
  path: string;
@@ -17,7 +17,7 @@ export declare const resolveCollection: <M extends Record<string, any>>({ collec
17
17
  */
18
18
  export declare function resolveProperty<T extends CMSType = CMSType, M extends Record<string, any> = any>({ propertyOrBuilder, fromBuilder, ignoreMissingFields, ...props }: {
19
19
  propertyKey?: string;
20
- propertyOrBuilder: PropertyOrBuilder<T, M> | ResolvedProperty<T>;
20
+ propertyOrBuilder: PropertyOrBuilder<T, M> | ResolvedProperty<T> | PropertyOrBuilder | Property | ResolvedProperty | undefined;
21
21
  values?: Partial<M>;
22
22
  previousValues?: Partial<M>;
23
23
  path?: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.1.0-canary.1df3b2c",
4
+ "version": "3.1.0-canary.24c8270",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,9 +53,9 @@
53
53
  "@dnd-kit/core": "^6.3.1",
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
- "@firecms/editor": "^3.1.0-canary.1df3b2c",
57
- "@firecms/formex": "^3.1.0-canary.1df3b2c",
58
- "@firecms/ui": "^3.1.0-canary.1df3b2c",
56
+ "@firecms/editor": "^3.1.0-canary.24c8270",
57
+ "@firecms/formex": "^3.1.0-canary.24c8270",
58
+ "@firecms/ui": "^3.1.0-canary.24c8270",
59
59
  "@radix-ui/react-portal": "^1.1.10",
60
60
  "clsx": "^2.1.1",
61
61
  "compressorjs": "^1.2.1",
@@ -76,8 +76,8 @@
76
76
  "yup": "^1.7.1"
77
77
  },
78
78
  "peerDependencies": {
79
- "react": ">=18.0.0",
80
- "react-dom": ">=18.0.0",
79
+ "react": ">=18.3.1 || >=19.0.0",
80
+ "react-dom": ">=18.3.1 || >=19.0.0",
81
81
  "react-router": "^6.28.0",
82
82
  "react-router-dom": "^6.28.0"
83
83
  },
@@ -89,8 +89,8 @@
89
89
  "@types/json-logic-js": "^2.0.8",
90
90
  "@types/node": "^20.19.17",
91
91
  "@types/object-hash": "^3.0.6",
92
- "@types/react": "^18.3.24",
93
- "@types/react-dom": "^18.3.7",
92
+ "@types/react": "^19.2.3",
93
+ "@types/react-dom": "^19.2.3",
94
94
  "@types/react-measure": "^2.0.12",
95
95
  "@vitejs/plugin-react": "^4.7.0",
96
96
  "babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417",
@@ -111,7 +111,7 @@
111
111
  "dist",
112
112
  "src"
113
113
  ],
114
- "gitHead": "5074584b15be0d0507a4dadc148f03d82e9fe495",
114
+ "gitHead": "fa98925bad34308ed66e7ea68adcc07eb38184ae",
115
115
  "publishConfig": {
116
116
  "access": "public"
117
117
  },
@@ -28,7 +28,7 @@ export function EntityTableCellActions({
28
28
  }
29
29
  }, []);
30
30
 
31
- const iconRef = useRef<HTMLButtonElement>();
31
+ const iconRef = useRef<HTMLButtonElement>(undefined);
32
32
  useEffect(() => {
33
33
  if (iconRef.current && selected) {
34
34
  iconRef.current.focus({ preventScroll: true });
@@ -1,20 +1,20 @@
1
1
  import React, { useCallback, useEffect } from "react";
2
2
 
3
3
  interface DraggableProps {
4
- containerRef: React.RefObject<HTMLDivElement>,
5
- innerRef: React.RefObject<HTMLDivElement>,
4
+ containerRef: React.RefObject<HTMLDivElement | null>,
5
+ innerRef: React.RefObject<HTMLDivElement | null>,
6
6
  x?: number;
7
7
  y?: number;
8
8
  onMove: (params: { x: number, y: number }) => void,
9
9
  }
10
10
 
11
11
  export function useDraggable({
12
- containerRef,
13
- innerRef,
14
- x,
15
- y,
16
- onMove
17
- }: DraggableProps) {
12
+ containerRef,
13
+ innerRef,
14
+ x,
15
+ y,
16
+ onMove
17
+ }: DraggableProps) {
18
18
 
19
19
  let relX = 0;
20
20
  let relY = 0;
@@ -62,9 +62,9 @@ export function useDraggable({
62
62
  if (event.target.localName === "input" || !listeningRef.current)
63
63
  return;
64
64
  onMove({
65
- x: event.screenX - relX,
66
- y: event.screenY - relY
67
- }
65
+ x: event.screenX - relX,
66
+ y: event.screenY - relY
67
+ }
68
68
  );
69
69
  event.stopPropagation();
70
70
  };
@@ -149,7 +149,7 @@ function EntityBoardCardInner<M extends Record<string, any> = any>({
149
149
  {/* Content */}
150
150
  <div className="flex-1 min-w-0">
151
151
  {/* Title */}
152
- <div className="truncate text-sm font-medium">
152
+ <div className="line-clamp-2 text-sm font-medium">
153
153
  {titleProperty && titleValue ? (
154
154
  <PropertyPreview
155
155
  propertyKey={titlePropertyKey as string}
@@ -111,6 +111,10 @@ export function EntityCard<M extends Record<string, any> = any>({
111
111
  return;
112
112
  }
113
113
  if (onClick) {
114
+ analyticsController.onAnalyticsEvent?.("card_view_entity_click", {
115
+ path: entity.path,
116
+ entityId: entity.id
117
+ });
114
118
  onClick(entity);
115
119
  }
116
120
  };
@@ -34,6 +34,7 @@ import {
34
34
  useFireCMSContext,
35
35
  useSideEntityController
36
36
  } from "../../hooks";
37
+ import { useAnalyticsController } from "../../hooks/useAnalyticsController";
37
38
  import { SaveEntityProps } from "../../types/datasource";
38
39
  import { setIn } from "@firecms/formex";
39
40
  import { useBoardDataController } from "./useBoardDataController";
@@ -74,6 +75,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
74
75
  const context = useFireCMSContext();
75
76
  const dataSource = useDataSource(collection);
76
77
  const sideEntityController = useSideEntityController();
78
+ const analyticsController = useAnalyticsController();
77
79
  const plugins = customizationController.plugins ?? [];
78
80
 
79
81
  // State for backfill dialog
@@ -222,6 +224,10 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
222
224
  }, [plugins]);
223
225
 
224
226
  const handleColumnReorder = useCallback((newColumns: string[]) => {
227
+ analyticsController.onAnalyticsEvent?.("kanban_column_reorder", {
228
+ path: fullPath,
229
+ columnProperty
230
+ });
225
231
  setHasUserReordered(true);
226
232
  setLocalColumnsOrder(newColumns);
227
233
  plugins
@@ -235,7 +241,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
235
241
  newColumnsOrder: newColumns
236
242
  });
237
243
  });
238
- }, [plugins, fullPath, parentCollectionIds, collection, columnProperty]);
244
+ }, [plugins, fullPath, parentCollectionIds, collection, columnProperty, analyticsController]);
239
245
 
240
246
  // Collection-level count queries to detect missing order property
241
247
  // Just TWO counts: total and ordered (for the entire collection, not per column)
@@ -393,6 +399,13 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
393
399
  const entity = items.find(item => item.id === moveInfo?.itemId)?.entity;
394
400
  if (!entity) return;
395
401
 
402
+ analyticsController.onAnalyticsEvent?.("kanban_card_moved", {
403
+ path: fullPath,
404
+ entityId: entity.id,
405
+ sourceColumn: moveInfo?.sourceColumn,
406
+ targetColumn: moveInfo?.targetColumn
407
+ });
408
+
396
409
  const isColumnChange = moveInfo && moveInfo.sourceColumn !== moveInfo.targetColumn;
397
410
 
398
411
  // If no orderProperty and not a column change, nothing to do
@@ -440,7 +453,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
440
453
  } catch (e) {
441
454
  console.error("Error saving entity:", e);
442
455
  }
443
- }, [collection, columnProperty, orderProperty, context, dataSource, calculateNewOrder, boardDataController]);
456
+ }, [collection, columnProperty, orderProperty, context, dataSource, calculateNewOrder, boardDataController, analyticsController, fullPath]);
444
457
 
445
458
  // Backfill order values for all entities
446
459
  const handleBackfill = useCallback(async () => {
@@ -449,6 +462,9 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
449
462
  console.log("No orderProperty, returning");
450
463
  return;
451
464
  }
465
+ analyticsController.onAnalyticsEvent?.("kanban_backfill_order", {
466
+ path: fullPath
467
+ });
452
468
  setBackfillLoading(true);
453
469
 
454
470
  try {
@@ -514,7 +530,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
514
530
  } finally {
515
531
  setBackfillLoading(false);
516
532
  }
517
- }, [orderProperty, fullPath, collection, dataSource, context, boardDataController]);
533
+ }, [orderProperty, fullPath, collection, dataSource, context, boardDataController, analyticsController]);
518
534
 
519
535
  const handleEntityClick = useCallback((entity: Entity<M>) => {
520
536
  onEntityClick?.(entity);
@@ -667,6 +683,10 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
667
683
  columnLoadingState={columnLoadingState}
668
684
  onLoadMoreColumn={(column) => boardDataController.loadMoreColumn(column)}
669
685
  onAddItemToColumn={(column) => {
686
+ analyticsController.onAnalyticsEvent?.("kanban_new_entity_in_column", {
687
+ path: fullPath,
688
+ column
689
+ });
670
690
  sideEntityController.open({
671
691
  path: fullPath,
672
692
  collection,
@@ -433,12 +433,17 @@ export const EntityCollectionView = React.memo(
433
433
 
434
434
  // View mode change: update URL + save to local persistence
435
435
  const onViewModeChange = useCallback((mode: ViewMode) => {
436
+ analyticsController.onAnalyticsEvent?.("view_mode_changed", {
437
+ path: fullPath,
438
+ from: viewMode,
439
+ to: mode
440
+ });
436
441
  setViewMode(mode);
437
442
  // Save to local persistence for next visit
438
443
  if (userConfigPersistence) {
439
444
  onCollectionModifiedForUser(fullPath, { defaultViewMode: mode } as PartialEntityCollection<M>);
440
445
  }
441
- }, [setViewMode, userConfigPersistence, onCollectionModifiedForUser, fullPath]);
446
+ }, [setViewMode, userConfigPersistence, onCollectionModifiedForUser, fullPath, analyticsController, viewMode]);
442
447
 
443
448
  const createEnabled = canCreateEntity(collection, authController, fullPath, null);
444
449
 
@@ -501,18 +506,24 @@ export const EntityCollectionView = React.memo(
501
506
  authController,
502
507
  }), [collection, fullPath]);
503
508
 
504
- // Check if Kanban view is available (needs kanban.columnProperty with enumValues)
505
- const kanbanEnabled = useMemo(() => {
506
- if (!collection.kanban?.columnProperty) return false;
507
- const property = getPropertyInPath(resolvedCollection.properties, collection.kanban.columnProperty);
508
- if (!property || !("dataType" in property) || property.dataType !== "string") return false;
509
- return Boolean(property.enumValues);
510
- }, [collection.kanban?.columnProperty, resolvedCollection.properties]);
509
+ // Check if Kanban view is possible (collection has at least one string enum property)
510
+ const hasEnumProperty = useMemo(() => {
511
+ const properties = resolvedCollection.properties;
512
+ return Object.values(properties).some((prop: any) =>
513
+ prop && prop.dataType === "string" && prop.enumValues
514
+ );
515
+ }, [resolvedCollection.properties]);
511
516
 
512
- // Check if a plugin can configure Kanban (has KanbanSetupComponent)
513
- const hasKanbanConfigPlugin = useMemo(() => {
514
- return customizationController.plugins?.some(plugin => plugin.collectionView?.KanbanSetupComponent) ?? false;
515
- }, [customizationController.plugins]);
517
+ // Compute the effective enabled views:
518
+ // - Start from collection.enabledViews (defaults to all three)
519
+ // - Filter out kanban if no enum properties exist
520
+ const enabledViews: ViewMode[] = useMemo(() => {
521
+ const configured = collection.enabledViews ?? ["table", "cards", "kanban"];
522
+ if (!hasEnumProperty) {
523
+ return configured.filter(v => v !== "kanban");
524
+ }
525
+ return configured;
526
+ }, [collection.enabledViews, hasEnumProperty]);
516
527
 
517
528
  // Compute available enum properties for kanban column selection
518
529
  const kanbanPropertyOptions: KanbanPropertyOption[] = useMemo(() => {
@@ -562,12 +573,16 @@ export const EntityCollectionView = React.memo(
562
573
 
563
574
  // Handle kanban property change
564
575
  const onKanbanPropertyChange = useCallback((property: string) => {
576
+ analyticsController.onAnalyticsEvent?.("kanban_property_changed", {
577
+ path: fullPath,
578
+ property
579
+ });
565
580
  setSelectedKanbanProperty(property);
566
581
  // Save to local persistence
567
582
  if (userConfigPersistence) {
568
583
  onCollectionModifiedForUser(fullPath, { kanbanColumnProperty: property } as any);
569
584
  }
570
- }, [userConfigPersistence, onCollectionModifiedForUser, fullPath]);
585
+ }, [userConfigPersistence, onCollectionModifiedForUser, fullPath, analyticsController]);
571
586
 
572
587
  const getPropertyFor = useCallback(({
573
588
  propertyKey,
@@ -819,8 +834,7 @@ export const EntityCollectionView = React.memo(
819
834
  <ViewModeToggle
820
835
  viewMode={viewMode}
821
836
  onViewModeChange={onViewModeChange}
822
- kanbanEnabled={kanbanEnabled}
823
- hasKanbanConfigPlugin={hasKanbanConfigPlugin}
837
+ enabledViews={enabledViews}
824
838
  size={viewMode === "table" ? tableSize : viewMode === "cards" ? cardSize : undefined}
825
839
  onSizeChanged={viewMode === "table" ? onTableSizeChanged : viewMode === "cards" ? setCardSize : undefined}
826
840
  open={viewModePopoverOpen}
@@ -831,6 +845,24 @@ export const EntityCollectionView = React.memo(
831
845
  />
832
846
  );
833
847
 
848
+ // Compute plugin-provided error view for collection loading errors
849
+ const pluginErrorView = useMemo(() => {
850
+ const error = tableController.dataLoadingError;
851
+ if (!error || !customizationController.plugins) return null;
852
+ for (const plugin of customizationController.plugins) {
853
+ if (plugin.collectionView?.CollectionError) {
854
+ const CollectionError = plugin.collectionView.CollectionError;
855
+ return <CollectionError
856
+ path={fullPath}
857
+ collection={collection}
858
+ parentCollectionIds={parentCollectionIds}
859
+ error={error}
860
+ />;
861
+ }
862
+ }
863
+ return null;
864
+ }, [tableController.dataLoadingError, customizationController.plugins, fullPath, collection, parentCollectionIds]);
865
+
834
866
  return (
835
867
  <div className={cls("overflow-hidden h-full w-full rounded-md flex flex-col", className)}
836
868
  ref={containerRef}>
@@ -867,7 +899,9 @@ export const EntityCollectionView = React.memo(
867
899
  />
868
900
 
869
901
  {/* View content - only the view-specific content changes */}
870
- {viewMode === "kanban" && (kanbanEnabled || hasKanbanConfigPlugin) ? (
902
+ {tableController.dataLoadingError && pluginErrorView
903
+ ? pluginErrorView
904
+ : viewMode === "kanban" && enabledViews.includes("kanban") ? (
871
905
  <EntityCollectionBoardView
872
906
  key={`kanban-view-${fullPath}-${selectedKanbanProperty}`}
873
907
  collection={collection}
@@ -22,16 +22,11 @@ export type ViewModeToggleProps = {
22
22
  viewMode?: ViewMode;
23
23
  onViewModeChange?: (mode: ViewMode) => void;
24
24
  /**
25
- * Whether Kanban view mode is available for this collection.
26
- * Should be true when collection.kanban is set with a valid enum property.
25
+ * Which view modes are enabled for this collection.
26
+ * Only these modes will appear in the toggle.
27
+ * Defaults to all three: ["table", "cards", "kanban"].
27
28
  */
28
- kanbanEnabled?: boolean;
29
- /**
30
- * Whether a plugin exists that can configure Kanban (e.g., collection editor).
31
- * When true, Kanban option is always shown (enabled or not based on kanbanEnabled).
32
- * When false, Kanban option is shown but disabled.
33
- */
34
- hasKanbanConfigPlugin?: boolean;
29
+ enabledViews?: ViewMode[];
35
30
  /**
36
31
  * Current size for card/table views
37
32
  */
@@ -62,11 +57,12 @@ export type ViewModeToggleProps = {
62
57
  onKanbanPropertyChange?: (property: string) => void;
63
58
  }
64
59
 
60
+ const ALL_VIEW_MODES: ViewMode[] = ["table", "cards", "kanban"];
61
+
65
62
  export function ViewModeToggle({
66
63
  viewMode = "table",
67
64
  onViewModeChange,
68
- kanbanEnabled = false,
69
- hasKanbanConfigPlugin = false,
65
+ enabledViews = ALL_VIEW_MODES,
70
66
  size,
71
67
  onSizeChanged,
72
68
  open,
@@ -93,16 +89,15 @@ export function ViewModeToggle({
93
89
  return "List";
94
90
  };
95
91
 
96
- const showKanban = kanbanEnabled || hasKanbanConfigPlugin;
97
92
  const showSizeSelector = size && onSizeChanged && (viewMode === "table" || viewMode === "cards");
98
93
  const showKanbanPropertySelector = viewMode === "kanban" &&
99
94
  kanbanPropertyOptions &&
100
95
  kanbanPropertyOptions.length > 0 &&
101
96
  onKanbanPropertyChange;
102
97
 
103
- // Build toggle options dynamically based on kanban availability
98
+ // Build toggle options based on enabledViews
104
99
  const viewModeOptions: ToggleButtonOption<ViewMode>[] = useMemo(() => {
105
- const options: ToggleButtonOption<ViewMode>[] = [
100
+ const allOptions: ToggleButtonOption<ViewMode>[] = [
106
101
  {
107
102
  value: "table",
108
103
  label: "List",
@@ -112,20 +107,21 @@ export function ViewModeToggle({
112
107
  value: "cards",
113
108
  label: "Cards",
114
109
  icon: <AppsIcon size="small" />
110
+ },
111
+ {
112
+ value: "kanban",
113
+ label: "Board",
114
+ icon: <ViewKanbanIcon size="small" />
115
115
  }
116
116
  ];
117
117
 
118
- if (showKanban) {
119
- options.push({
120
- value: "kanban",
121
- label: "Board",
122
- icon: <ViewKanbanIcon size="small" />,
123
- disabled: !kanbanEnabled && !hasKanbanConfigPlugin
124
- });
125
- }
118
+ return allOptions.filter(option => enabledViews.includes(option.value));
119
+ }, [enabledViews]);
126
120
 
127
- return options;
128
- }, [showKanban, kanbanEnabled, hasKanbanConfigPlugin]);
121
+ // Don't render if only one view is enabled
122
+ if (viewModeOptions.length <= 1 && !showSizeSelector) {
123
+ return null;
124
+ }
129
125
 
130
126
  return (
131
127
  <Popover
@@ -141,11 +137,13 @@ export function ViewModeToggle({
141
137
  >
142
138
  <div className="p-3 flex flex-col gap-3 min-w-[240px]">
143
139
  {/* View mode toggle using ToggleButtonGroup */}
144
- <ToggleButtonGroup
145
- value={viewMode}
146
- onValueChange={onViewModeChange}
147
- options={viewModeOptions}
148
- />
140
+ {viewModeOptions.length > 1 && (
141
+ <ToggleButtonGroup
142
+ value={viewMode}
143
+ onValueChange={onViewModeChange}
144
+ options={viewModeOptions}
145
+ />
146
+ )}
149
147
 
150
148
  {/* Size selector */}
151
149
  {showSizeSelector && (
@@ -199,4 +197,3 @@ export function ViewModeToggle({
199
197
  </Popover>
200
198
  );
201
199
  }
202
-