@firecms/core 3.2.0 → 3.3.0-canary.451aa49

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 (67) hide show
  1. package/dist/components/VirtualTable/VirtualTableHeader.d.ts +1 -0
  2. package/dist/components/VirtualTable/VirtualTableHeaderRow.d.ts +1 -1
  3. package/dist/components/VirtualTable/VirtualTableProps.d.ts +6 -1
  4. package/dist/components/VirtualTable/types.d.ts +1 -0
  5. package/dist/form/field_bindings/MapFieldBinding.d.ts +1 -1
  6. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +20186 -19539
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +24292 -23645
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/types/collections.d.ts +38 -0
  13. package/dist/types/properties.d.ts +9 -8
  14. package/dist/types/translations.d.ts +23 -0
  15. package/dist/util/index.d.ts +1 -0
  16. package/dist/util/lazy_eager.d.ts +7 -0
  17. package/dist/util/objects.d.ts +1 -0
  18. package/package.json +4 -4
  19. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +9 -3
  20. package/src/components/EntityCollectionView/EntityCollectionView.tsx +3 -5
  21. package/src/components/EntityJsonPreview.tsx +2 -1
  22. package/src/components/ErrorBoundary.tsx +3 -3
  23. package/src/components/VirtualTable/VirtualTable.tsx +5 -3
  24. package/src/components/VirtualTable/VirtualTableHeader.tsx +9 -8
  25. package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +8 -3
  26. package/src/components/VirtualTable/VirtualTableProps.tsx +7 -1
  27. package/src/components/VirtualTable/types.tsx +1 -0
  28. package/src/core/DrawerNavigationGroup.tsx +1 -1
  29. package/src/core/EntityEditView.tsx +50 -5
  30. package/src/core/EntitySidePanel.tsx +2 -1
  31. package/src/core/field_configs.tsx +4 -2
  32. package/src/form/EntityForm.tsx +64 -4
  33. package/src/form/PropertyFieldBinding.tsx +4 -3
  34. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +18 -5
  35. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +18 -5
  36. package/src/form/field_bindings/BlockFieldBinding.tsx +21 -7
  37. package/src/form/field_bindings/DateTimeFieldBinding.tsx +1 -1
  38. package/src/form/field_bindings/KeyValueFieldBinding.tsx +23 -6
  39. package/src/form/field_bindings/MapFieldBinding.tsx +23 -8
  40. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +43 -20
  41. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +15 -1
  42. package/src/form/field_bindings/ReferenceAsStringFieldBinding.tsx +25 -11
  43. package/src/form/field_bindings/ReferenceFieldBinding.tsx +25 -11
  44. package/src/form/field_bindings/RepeatFieldBinding.tsx +18 -5
  45. package/src/form/field_bindings/SelectFieldBinding.tsx +7 -5
  46. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +24 -7
  47. package/src/form/field_bindings/SwitchFieldBinding.tsx +31 -14
  48. package/src/form/field_bindings/TextFieldBinding.tsx +10 -7
  49. package/src/form/field_bindings/UserSelectFieldBinding.tsx +7 -5
  50. package/src/index.ts +1 -0
  51. package/src/locales/de.ts +28 -1
  52. package/src/locales/en.ts +27 -0
  53. package/src/locales/es.ts +28 -1
  54. package/src/locales/fr.ts +28 -1
  55. package/src/locales/hi.ts +28 -1
  56. package/src/locales/it.ts +28 -1
  57. package/src/locales/pt.ts +28 -1
  58. package/src/preview/PropertyPreview.tsx +3 -2
  59. package/src/preview/components/ReferencePreview.tsx +2 -1
  60. package/src/preview/property_previews/MapPropertyPreview.tsx +49 -27
  61. package/src/routes/FireCMSRoute.tsx +63 -54
  62. package/src/types/collections.ts +40 -0
  63. package/src/types/properties.ts +11 -10
  64. package/src/types/translations.ts +27 -0
  65. package/src/util/index.ts +1 -0
  66. package/src/util/lazy_eager.tsx +33 -0
  67. package/src/util/objects.ts +15 -0
@@ -142,6 +142,30 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
142
142
  * the side dialog of an entity.
143
143
  */
144
144
  subcollections?: EntityCollection<any, any>[];
145
+ /**
146
+ * You can group subcollections and custom views into dropdown menus
147
+ * in the entity view tabs. Views listed in a group will be removed
148
+ * from the top-level tabs and shown under a single dropdown instead.
149
+ *
150
+ * @example
151
+ * ```tsx
152
+ * const productsCollection = buildCollection({
153
+ * id: "products",
154
+ * path: "products",
155
+ * name: "Products",
156
+ * properties: { ... },
157
+ * subcollections: [localesCollection, reviewsCollection],
158
+ * entityViews: [sampleView],
159
+ * viewGroups: [
160
+ * {
161
+ * name: "Related data",
162
+ * views: ["locales", "reviews", "sample_view"]
163
+ * }
164
+ * ]
165
+ * });
166
+ * ```
167
+ */
168
+ viewGroups?: ViewGroup[];
145
169
  /**
146
170
  * This interface defines all the callbacks that can be used when an entity
147
171
  * is being created, updated or deleted.
@@ -361,6 +385,20 @@ export interface KanbanConfig<M extends Record<string, any> = any> {
361
385
  */
362
386
  columnProperty: Extract<keyof M, string>;
363
387
  }
388
+ /**
389
+ * You can group subcollections and custom views into dropdown menus in the entity view tabs.
390
+ * @group Collections
391
+ */
392
+ export interface ViewGroup {
393
+ /**
394
+ * Name of the group
395
+ */
396
+ name: string;
397
+ /**
398
+ * Array of subcollection paths/ids or custom view keys
399
+ */
400
+ views: string[];
401
+ }
364
402
  /**
365
403
  * View mode for displaying a collection.
366
404
  * @group Collections
@@ -125,6 +125,15 @@ export interface BaseProperty<T extends CMSType, CustomProps = any> {
125
125
  * @see https://jsonlogic.com/ for JSON Logic syntax
126
126
  */
127
127
  conditions?: PropertyConditions;
128
+ /**
129
+ * Set this property to true to provide the UX to explicitly set the value to `null`.
130
+ * Defaults to `false`.
131
+ */
132
+ nullable?: boolean;
133
+ /**
134
+ * @deprecated Use `nullable` instead.
135
+ */
136
+ clearable?: boolean;
128
137
  }
129
138
  /**
130
139
  * @group Entity properties
@@ -473,10 +482,6 @@ export interface NumberProperty extends BaseProperty<number> {
473
482
  * Rules for validating this property
474
483
  */
475
484
  validation?: NumberPropertyValidationSchema;
476
- /**
477
- * Add an icon to clear the value and set it to `null`. Defaults to `false`
478
- */
479
- clearable?: boolean;
480
485
  }
481
486
  /**
482
487
  * @group Entity properties
@@ -569,10 +574,6 @@ export interface StringProperty extends BaseProperty<string> {
569
574
  * Rules for validating this property
570
575
  */
571
576
  validation?: StringPropertyValidationSchema;
572
- /**
573
- * Add an icon to clear the value and set it to `null`. Defaults to `false`
574
- */
575
- clearable?: boolean;
576
577
  /**
577
578
  * You can use this property (a string) to behave as a reference to another
578
579
  * collection. The stored value is the ID of the entity in the
@@ -412,6 +412,8 @@ export interface FireCMSTranslations {
412
412
  cms_users: string;
413
413
  roles_menu: string;
414
414
  project_settings: string;
415
+ firestore_explorer: string;
416
+ explore_your_firestore_data: string;
415
417
  build_admin_panel_in_minutes: string;
416
418
  go_live_instantly: string;
417
419
  create_production_ready_back_offices: string;
@@ -643,4 +645,25 @@ export interface FireCMSTranslations {
643
645
  settings_appcheck_refresh_note: string;
644
646
  settings_appcheck_updated: string;
645
647
  settings_appcheck_error: string;
648
+ missing_firestore_security_rules: string;
649
+ firecms_cloud_requires_security_rule: string;
650
+ cannot_be_accessed_without_it: string;
651
+ required_security_rule: string;
652
+ fix_automatically: string;
653
+ open_firebase_rules: string;
654
+ security_rules_updated_successfully: string;
655
+ sec_rules_fixing: string;
656
+ sec_rules_fixed: string;
657
+ marketplace_managed_by_gcp: string;
658
+ marketplace_billing_note: string;
659
+ marketplace_manage_in_gcp_console: string;
660
+ marketplace_plan_changes_note: string;
661
+ marketplace_welcome_title: string;
662
+ marketplace_welcome_subtitle: string;
663
+ marketplace_select_or_create_project: string;
664
+ marketplace_link_project: string;
665
+ marketplace_linking: string;
666
+ marketplace_link_success: string;
667
+ marketplace_link_error: string;
668
+ marketplace_no_account_id: string;
646
669
  }
@@ -25,3 +25,4 @@ export * from "./useTraceUpdate";
25
25
  export * from "./storage";
26
26
  export * from "./callbacks";
27
27
  export * from "./conditions";
28
+ export * from "./lazy_eager";
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ /**
3
+ * Returns a React.lazy component that is also preloaded immediately using
4
+ * requestIdleCallback or setTimeout.
5
+ * This ensures that chunks are split, but fetched in the background before they are actually needed.
6
+ */
7
+ export declare function lazyEager<T extends React.ComponentType<any>>(factory: () => Promise<any>, exportName?: string): React.LazyExoticComponent<T>;
@@ -10,3 +10,4 @@ export declare function removeUndefined(value: any, removeEmptyStrings?: boolean
10
10
  export declare function removeNulls(value: any): any;
11
11
  export declare function isEmptyObject(obj: object): boolean;
12
12
  export declare function removePropsIfExisting(source: any, comparison: any): any;
13
+ export declare function jsonStringifyReplacer(key: string, value: any): any;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.2.0",
4
+ "version": "3.3.0-canary.451aa49",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,8 +53,8 @@
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/formex": "3.2.0",
57
- "@firecms/ui": "3.2.0",
56
+ "@firecms/formex": "^3.3.0-canary.451aa49",
57
+ "@firecms/ui": "^3.3.0-canary.451aa49",
58
58
  "@floating-ui/dom": "^1.7.4",
59
59
  "@radix-ui/react-portal": "^1.1.10",
60
60
  "@radix-ui/react-slot": "^1.2.4",
@@ -136,7 +136,7 @@
136
136
  "dist",
137
137
  "src"
138
138
  ],
139
- "gitHead": "4c3b8f2c16265fcdd6bf443cf5b420a7a7332d9d",
139
+ "gitHead": "772b4a7f64893038f0cc13669d6bc66ec858fc49",
140
140
  "publishConfig": {
141
141
  "access": "public"
142
142
  },
@@ -5,6 +5,7 @@ import { Badge, Checkbox, cls, IconButton, Menu, MenuItem, MoreVertIcon, Skeleto
5
5
  import { useFireCMSContext, useLargeLayout } from "../../hooks";
6
6
  import { getEntityFromCache } from "../../util/entity_cache";
7
7
  import { getLocalChangesBackup } from "../../util";
8
+ import { getChanges } from "../../form/EntityForm";
8
9
 
9
10
  /**
10
11
  *
@@ -81,13 +82,18 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
81
82
  const collapsedActions = actions.filter(a => a.collapsed || a.collapsed === undefined);
82
83
  const uncollapsedActions = actions.filter(a => a.collapsed === false);
83
84
  const enableLocalChangesBackup = collection ? getLocalChangesBackup(collection) : false;
84
- const hasDraft = enableLocalChangesBackup ? getEntityFromCache(fullPath + "/" + entity.id) : false;
85
+ const cachedData = enableLocalChangesBackup ? getEntityFromCache(fullPath + "/" + entity.id) : undefined;
86
+ const hasDraft = (() => {
87
+ if (!cachedData || typeof cachedData !== "object" || Object.keys(cachedData).length === 0) return false;
88
+ const realChanges = getChanges(cachedData as any, (entity?.values ?? {}) as any);
89
+ return Object.keys(realChanges).length > 0;
90
+ })();
85
91
  const iconSize = largeLayout && (size === "m" || size === "l" || size == "xl") ? "medium" : "small";
86
92
 
87
93
  const content = (
88
94
  <div
89
95
  className={cls(
90
- "h-full flex items-center justify-center flex-col bg-surface-50 dark:bg-surface-900 bg-opacity-90 bg-surface-50/90 dark:bg-opacity-90 dark:bg-surface-900/90 z-10",
96
+ "h-full flex items-center justify-center flex-col bg-surface-50 dark:bg-surface-900 bg-opacity-90 bg-surface-50/90 dark:bg-opacity-90 dark:bg-surface-900/90 z-10 shrink-0",
91
97
  frozen ? "sticky left-0" : ""
92
98
  )}
93
99
  onClick={useCallback((event: any) => {
@@ -101,7 +107,7 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
101
107
  }}>
102
108
 
103
109
  {(hasActions || selectionEnabled) &&
104
- <div className="w-34 flex justify-center">
110
+ <div className="w-full flex justify-center">
105
111
 
106
112
  {uncollapsedActions.map((action, index) => {
107
113
  const isEditAction = action.key === "edit";
@@ -904,11 +904,9 @@ export const EntityCollectionView = React.memo(
904
904
  />
905
905
 
906
906
  {/* View content - only the view-specific content changes */}
907
- {tableController.dataLoadingError && pluginErrorView}
908
- {tableController.dataLoadingError && !pluginErrorView && (
909
- <CollectionDataErrorBanner error={tableController.dataLoadingError} />
910
- )}
911
- {viewMode === "kanban" && enabledViews.includes("kanban") ? (
907
+ {tableController.dataLoadingError ? (
908
+ pluginErrorView ?? <CollectionDataErrorBanner error={tableController.dataLoadingError} />
909
+ ) : viewMode === "kanban" && enabledViews.includes("kanban") ? (
912
910
  <EntityCollectionBoardView
913
911
  key={`kanban-view-${fullPath}-${selectedKanbanProperty}`}
914
912
  collection={collection}
@@ -1,9 +1,10 @@
1
1
  import React, { useRef } from "react";
2
2
  import { Highlight, themes } from "prism-react-renderer";
3
3
  import { useModeController } from "../hooks";
4
+ import { jsonStringifyReplacer } from "../util/objects";
4
5
 
5
6
  export function EntityJsonPreview({ values }: { values: object }) {
6
- const code = JSON.stringify(values, null, "\t");
7
+ const code = JSON.stringify(values, jsonStringifyReplacer, "\t");
7
8
  const { mode } = useModeController();
8
9
  const preRef = useRef<HTMLPreElement>(null);
9
10
 
@@ -35,10 +35,10 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
35
35
  function FallbackView({ message }: { message?: string }) {
36
36
  const { t } = useTranslation();
37
37
  return (
38
- <div className="h-full w-full bg-slate-100 flex items-center justify-center p-4">
38
+ <div className="h-full w-full bg-slate-100 dark:bg-surface-900 flex items-center justify-center p-4">
39
39
  <div
40
- className="flex flex-col items-center justify-center m-4 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
41
- <div className="flex items-center mb-4 text-red-500">
40
+ className="flex flex-col items-center justify-center m-4 bg-white dark:bg-surface-800 p-8 rounded-lg shadow-sm border border-gray-200 dark:border-surface-700">
41
+ <div className="flex items-center mb-4 text-red-500 dark:text-red-400">
42
42
  <ErrorIcon/>
43
43
  <div className="ml-4">{t("error")}</div>
44
44
  </div>
@@ -126,6 +126,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
126
126
  AddColumnComponent,
127
127
  initialScroll = 0,
128
128
  onColumnsOrderChange,
129
+ headerIconSize,
129
130
  }: VirtualTableProps<T>) {
130
131
 
131
132
  const sortByProperty: string | undefined = sortBy ? sortBy[0] : undefined;
@@ -211,7 +212,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
211
212
  }, [tableRef]);
212
213
 
213
214
  const [measureRef, bounds] = useMeasure({
214
- debounce: 50,
215
+ debounce: 0,
215
216
  polyfill: ResizeObserver,
216
217
  scroll: true,
217
218
  // This is important for handling zooming in react-flow
@@ -369,8 +370,9 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
369
370
  setColumns(newColumns);
370
371
  onColumnsOrderChange(newColumns);
371
372
  } : undefined,
372
- draggingColumnId
373
- }), [data, rowHeight, cellRenderer, columns, currentSort, onRowClick, customView, onColumnResizeInternal, onColumnResizeEndInternal, filterInput, onColumnSort, onFilterUpdateInternal, sortByProperty, hoverRow, createFilterField, rowClassName, endAdornment, AddColumnComponent, onColumnsOrderChange, draggingColumnId]);
373
+ draggingColumnId,
374
+ headerIconSize,
375
+ }), [data, rowHeight, cellRenderer, columns, currentSort, onRowClick, customView, onColumnResizeInternal, onColumnResizeEndInternal, filterInput, onColumnSort, onFilterUpdateInternal, sortByProperty, hoverRow, createFilterField, rowClassName, endAdornment, AddColumnComponent, onColumnsOrderChange, draggingColumnId, headerIconSize]);
374
376
 
375
377
  // Get sortable column keys (excluding frozen columns)
376
378
  const sortableColumnKeys = columns
@@ -48,6 +48,7 @@ type VirtualTableHeaderProps<M extends Record<string, any>> = {
48
48
  AdditionalHeaderWidget?: (props: { onHover: boolean }) => React.ReactNode;
49
49
  isDragging?: boolean;
50
50
  isDraggable?: boolean;
51
+ headerIconSize?: "small" | "smallest";
51
52
  };
52
53
 
53
54
  export const VirtualTableHeader = React.memo<VirtualTableHeaderProps<any>>(
@@ -64,7 +65,8 @@ export const VirtualTableHeader = React.memo<VirtualTableHeaderProps<any>>(
64
65
  createFilterField,
65
66
  AdditionalHeaderWidget,
66
67
  isDragging,
67
- isDraggable
68
+ isDraggable,
69
+ headerIconSize = "small",
68
70
  }: VirtualTableHeaderProps<M>) {
69
71
 
70
72
  const [onHover, setOnHover] = useState(false);
@@ -136,18 +138,17 @@ export const VirtualTableHeader = React.memo<VirtualTableHeaderProps<any>>(
136
138
  <Badge color="secondary"
137
139
  invisible={!sort}>
138
140
  <IconButton
139
- size={"small"}
141
+ size={headerIconSize}
140
142
  className={onHover || openFilter ? "bg-white dark:bg-surface-950" : undefined}
141
143
  onClick={() => {
142
144
  onColumnSort(column.key as Extract<keyof M, string>);
143
145
  }}
144
146
  >
145
- {!sort &&
146
- <ArrowUpwardIcon />}
147
- {sort === "asc" &&
148
- <ArrowUpwardIcon />}
149
- {sort === "desc" &&
150
- <ArrowUpwardIcon className={"rotate-180"} />}
147
+ <ArrowUpwardIcon size={headerIconSize}
148
+ className={cls(
149
+ "transition-transform duration-200",
150
+ sort === "desc" ? "rotate-180" : "rotate-0"
151
+ )} />
151
152
  </IconButton>
152
153
  </Badge>
153
154
  }
@@ -22,7 +22,8 @@ const SortableColumnHeader = ({
22
22
  onClickResizeColumn,
23
23
  createFilterField,
24
24
  isDragging,
25
- isDraggable
25
+ isDraggable,
26
+ headerIconSize
26
27
  }: {
27
28
  column: VirtualTableColumn;
28
29
  columnIndex: number;
@@ -37,6 +38,7 @@ const SortableColumnHeader = ({
37
38
  createFilterField: any;
38
39
  isDragging: boolean;
39
40
  isDraggable: boolean;
41
+ headerIconSize?: "small" | "smallest";
40
42
  }) => {
41
43
  const [isPressing, setIsPressing] = useState(false);
42
44
 
@@ -103,7 +105,8 @@ const SortableColumnHeader = ({
103
105
  createFilterField={createFilterField}
104
106
  AdditionalHeaderWidget={column.AdditionalHeaderWidget}
105
107
  isDragging={isDragging || isPressing}
106
- isDraggable={isDraggable} />
108
+ isDraggable={isDraggable}
109
+ headerIconSize={headerIconSize} />
107
110
  </div>
108
111
  );
109
112
  };
@@ -123,7 +126,8 @@ export const VirtualTableHeaderRow = ({
123
126
  data,
124
127
  cellRenderer: CellRenderer,
125
128
  rowHeight = 54,
126
- draggingColumnId
129
+ draggingColumnId,
130
+ headerIconSize,
127
131
  }: VirtualTableContextProps<any>) => {
128
132
 
129
133
  const columnRefs = useMemo(() => columns.map(() => createRef<HTMLDivElement>()), [columns.length]);
@@ -234,6 +238,7 @@ export const VirtualTableHeaderRow = ({
234
238
  createFilterField={createFilterField}
235
239
  isDragging={isDragging}
236
240
  isDraggable={isDraggable}
241
+ headerIconSize={headerIconSize}
237
242
  />
238
243
  </ErrorBoundary>
239
244
  );
@@ -168,6 +168,12 @@ export interface VirtualTableProps<T extends Record<string, any>> {
168
168
  */
169
169
  onColumnsOrderChange?: (columns: VirtualTableColumn[]) => void;
170
170
 
171
+ /**
172
+ * Size of icons in column headers (sort, filter).
173
+ * @default "small"
174
+ */
175
+ headerIconSize?: "small" | "smallest";
176
+
171
177
  }
172
178
 
173
179
  export type CellRendererParams<T = any> = {
@@ -206,7 +212,7 @@ export interface VirtualTableColumn<CustomProps = any> {
206
212
  /**
207
213
  * Label displayed in the header
208
214
  */
209
- title?: string;
215
+ title?: React.ReactNode;
210
216
 
211
217
  /**
212
218
  * This column is frozen to the left
@@ -42,4 +42,5 @@ export type VirtualTableContextProps<T extends any> = {
42
42
  AddColumnComponent?: React.ComponentType;
43
43
  onColumnsOrderChange?: (columns: VirtualTableColumn[]) => void;
44
44
  draggingColumnId?: string | null;
45
+ headerIconSize?: "small" | "smallest";
45
46
  };
@@ -83,7 +83,7 @@ export function DrawerNavigationGroup({
83
83
  color={"secondary"}
84
84
  className="font-medium flex-grow line-clamp-1"
85
85
  >
86
- {(group || t("views_group")).toUpperCase()}
86
+ {(group && group !== "__default__" ? group : t("views_group")).toUpperCase()}
87
87
  </Typography>
88
88
  {headerActions && (
89
89
  <div onClick={(e) => e.stopPropagation()}>
@@ -26,7 +26,7 @@ import {
26
26
  useFireCMSContext,
27
27
  useLargeLayout
28
28
  } from "../hooks";
29
- import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography } from "@firecms/ui";
29
+ import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
30
30
  import { getEntityFromMemoryCache } from "../util/entity_cache";
31
31
  import { EntityForm, EntityFormProps } from "../form";
32
32
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
@@ -230,6 +230,10 @@ export function EntityEditViewInner<M extends Record<string, any>>({
230
230
  const includeJsonView = collection.includeJsonView === undefined ? true : collection.includeJsonView;
231
231
  const hasAdditionalViews = customViewsCount > 0 || subcollectionsCount > 0 || includeJsonView;
232
232
 
233
+ const groupedViews = useMemo(() => {
234
+ return (collection.viewGroups ?? []).flatMap(g => g.views);
235
+ }, [collection.viewGroups]);
236
+
233
237
  const {
234
238
  resolvedEntityViews,
235
239
  selectedEntityView,
@@ -419,16 +423,18 @@ export function EntityEditViewInner<M extends Record<string, any>>({
419
423
  Builder={selectedSecondaryForm?.Builder}
420
424
  />;
421
425
 
422
- const subcollectionTabs = subcollections && subcollections.map((subcollection) =>
426
+ const subcollectionTabs = subcollections && subcollections
427
+ .filter(sub => !groupedViews.includes(sub.id ?? sub.path))
428
+ .map((subcollection) =>
423
429
  <Tab
424
430
  className="text-sm min-w-[120px]"
425
- value={subcollection.id}
431
+ value={subcollection.id ?? subcollection.path}
426
432
  key={`entity_detail_collection_tab_${subcollection.name}`}>
427
433
  {subcollection.name}
428
434
  </Tab>
429
435
  );
430
436
 
431
- const customViewTabsStart = resolvedEntityViews.filter(view => view.position === "start")
437
+ const customViewTabsStart = resolvedEntityViews.filter(view => view.position === "start" && !groupedViews.includes(view.key))
432
438
  .map((view) =>
433
439
  <Tab
434
440
  className={!view.tabComponent ? "text-sm min-w-[120px]" : undefined}
@@ -437,7 +443,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
437
443
  {view.tabComponent ?? view.name}
438
444
  </Tab>
439
445
  );
440
- const customViewTabsEnd = resolvedEntityViews.filter(view => !view.position || view.position === "end")
446
+ const customViewTabsEnd = resolvedEntityViews.filter(view => (!view.position || view.position === "end") && !groupedViews.includes(view.key))
441
447
  .map((view) =>
442
448
  <Tab
443
449
  className={!view.tabComponent ? "text-sm min-w-[120px]" : undefined}
@@ -447,6 +453,43 @@ export function EntityEditViewInner<M extends Record<string, any>>({
447
453
  </Tab>
448
454
  );
449
455
 
456
+ const viewGroupMenus = collection.viewGroups?.map(group => {
457
+ const isActive = group.views.includes(selectedTab);
458
+ return (
459
+ <Menu
460
+ key={`view_group_${group.name}`}
461
+ trigger={
462
+ <button
463
+ type="button"
464
+ className={cls(
465
+ "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all",
466
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-400 focus-visible:ring-offset-2",
467
+ "disabled:pointer-events-none disabled:opacity-50",
468
+ isActive ? "bg-white text-surface-900 dark:bg-surface-950 dark:text-surface-50" : "text-surface-600 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-800"
469
+ )}
470
+ >
471
+ {group.name}
472
+ <ExpandMoreIcon className="ml-1 -mr-1" size="small" />
473
+ </button>
474
+ }>
475
+ {group.views.map(viewId => {
476
+ const subcollection = subcollections.find(s => (s.id ?? s.path) === viewId);
477
+ const customView = resolvedEntityViews.find(v => v.key === viewId);
478
+ const name = subcollection?.name ?? customView?.name ?? viewId;
479
+ return (
480
+ <MenuItem
481
+ key={`view_group_${group.name}_${viewId}`}
482
+ onClick={() => onSideTabClick(viewId)}
483
+ className={selectedTab === viewId ? "bg-surface-accent-100 dark:bg-surface-accent-900" : ""}
484
+ >
485
+ {name}
486
+ </MenuItem>
487
+ );
488
+ })}
489
+ </Menu>
490
+ );
491
+ });
492
+
450
493
  const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews;
451
494
 
452
495
  let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-900">
@@ -494,6 +537,8 @@ export function EntityEditViewInner<M extends Record<string, any>>({
494
537
 
495
538
  {customViewTabsEnd}
496
539
 
540
+ {viewGroupMenus}
541
+
497
542
  {subcollectionTabs}
498
543
  </Tabs>}
499
544
  </div>}
@@ -78,7 +78,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
78
78
  return navigationController.getParentCollectionIds(path);
79
79
  }, [navigationController, path]);
80
80
 
81
- const collection = navigationController.getCollection(fullIdPath ?? path) ?? props.collection;
81
+ const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
82
82
 
83
83
  useEffect(() => {
84
84
  function beforeunload(e: any) {
@@ -112,6 +112,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
112
112
  return (
113
113
  <>
114
114
  <ErrorBoundary>
115
+
115
116
  <EntityEditView
116
117
  {...props}
117
118
  fullIdPath={fullIdPath}
@@ -8,16 +8,18 @@ import {
8
8
  DateTimeFieldBinding,
9
9
  KeyValueFieldBinding,
10
10
  MapFieldBinding,
11
- MarkdownEditorFieldBinding,
12
11
  MultiSelectFieldBinding,
13
12
  ReferenceAsStringFieldBinding,
14
13
  ReferenceFieldBinding,
15
14
  RepeatFieldBinding,
16
15
  SelectFieldBinding,
17
- StorageUploadFieldBinding,
18
16
  SwitchFieldBinding,
19
17
  TextFieldBinding
20
18
  } from "../form";
19
+ import { lazyEager } from "../util/lazy_eager";
20
+
21
+ const MarkdownEditorFieldBinding = lazyEager<typeof import("../form/field_bindings/MarkdownEditorFieldBinding")["MarkdownEditorFieldBinding"]>(() => import("../form/field_bindings/MarkdownEditorFieldBinding"), "MarkdownEditorFieldBinding");
22
+ const StorageUploadFieldBinding = lazyEager<typeof import("../form/field_bindings/StorageUploadFieldBinding")["StorageUploadFieldBinding"]>(() => import("../form/field_bindings/StorageUploadFieldBinding"), "StorageUploadFieldBinding");
21
23
  import { isPropertyBuilder, mergeDeep } from "../util";
22
24
 
23
25
  import {