@firecms/core 3.3.0 → 3.4.0-canary.2575a08

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 (155) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  3. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  4. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  7. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  8. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  9. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  10. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  11. package/dist/components/EntityPreview.d.ts +3 -1
  12. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  13. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  14. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  15. package/dist/components/common/table_url_params.d.ts +24 -0
  16. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  17. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  18. package/dist/core/DefaultDrawer.d.ts +10 -3
  19. package/dist/core/EntityEditView.d.ts +9 -1
  20. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  21. package/dist/core/field_configs.d.ts +1 -1
  22. package/dist/form/EntityForm.d.ts +8 -1
  23. package/dist/form/EntityFormActions.d.ts +2 -0
  24. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  25. package/dist/form/index.d.ts +1 -0
  26. package/dist/hooks/data/delete.d.ts +1 -1
  27. package/dist/hooks/data/save.d.ts +1 -1
  28. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  29. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  30. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  31. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  32. package/dist/index.es.js +3658 -1820
  33. package/dist/index.es.js.map +1 -1
  34. package/dist/index.umd.js +3655 -1817
  35. package/dist/index.umd.js.map +1 -1
  36. package/dist/locales/pl.d.ts +2 -0
  37. package/dist/preview/index.d.ts +1 -0
  38. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  39. package/dist/types/collections.d.ts +31 -0
  40. package/dist/types/datasource.d.ts +67 -4
  41. package/dist/types/entities.d.ts +21 -1
  42. package/dist/types/entity_actions.d.ts +6 -0
  43. package/dist/types/entity_callbacks.d.ts +21 -0
  44. package/dist/types/navigation.d.ts +34 -5
  45. package/dist/types/plugins.d.ts +13 -0
  46. package/dist/types/properties.d.ts +17 -1
  47. package/dist/types/side_entity_controller.d.ts +7 -0
  48. package/dist/types/translations.d.ts +6 -0
  49. package/dist/util/__tests__/collections.test.d.ts +1 -0
  50. package/dist/util/__tests__/urls.test.d.ts +1 -0
  51. package/dist/util/collections.d.ts +1 -1
  52. package/dist/util/entities.d.ts +1 -0
  53. package/dist/util/entity_cache.d.ts +11 -0
  54. package/dist/util/geopoint.d.ts +22 -0
  55. package/dist/util/index.d.ts +2 -0
  56. package/dist/util/navigation_blocking.d.ts +22 -0
  57. package/dist/util/navigation_from_path.d.ts +17 -0
  58. package/dist/util/navigation_utils.d.ts +107 -3
  59. package/dist/util/parent_references_from_path.d.ts +16 -0
  60. package/dist/util/paths.d.ts +28 -0
  61. package/dist/util/permissions.d.ts +10 -4
  62. package/dist/util/urls.d.ts +22 -0
  63. package/package.json +17 -10
  64. package/src/app/Scaffold.tsx +34 -44
  65. package/src/app/useApp.tsx +1 -0
  66. package/src/components/DeleteEntityDialog.tsx +2 -0
  67. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  68. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  69. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  70. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  71. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  72. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  73. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  74. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  75. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  76. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +8 -1
  77. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  78. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  79. package/src/components/EntityPreview.tsx +45 -41
  80. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  81. package/src/components/ReferenceWidget.tsx +1 -1
  82. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  83. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  84. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  85. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  86. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  87. package/src/components/common/default_entity_actions.tsx +22 -2
  88. package/src/components/common/table_url_params.ts +172 -0
  89. package/src/components/common/useDataSourceTableController.tsx +126 -163
  90. package/src/components/common/useTableSearchHelper.ts +26 -2
  91. package/src/contexts/SnackbarProvider.tsx +14 -1
  92. package/src/core/DefaultDrawer.tsx +150 -64
  93. package/src/core/DrawerNavigationGroup.tsx +27 -29
  94. package/src/core/DrawerNavigationItem.tsx +10 -12
  95. package/src/core/EntityEditView.tsx +77 -35
  96. package/src/core/EntityEditViewFormActions.tsx +3 -2
  97. package/src/core/EntitySidePanel.tsx +8 -4
  98. package/src/core/field_configs.tsx +15 -0
  99. package/src/form/EntityForm.tsx +31 -10
  100. package/src/form/EntityFormActions.tsx +2 -0
  101. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  102. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  103. package/src/form/index.tsx +1 -0
  104. package/src/hooks/data/delete.ts +8 -0
  105. package/src/hooks/data/save.ts +6 -1
  106. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  107. package/src/hooks/data/useEntityFetch.tsx +35 -4
  108. package/src/hooks/useBuildNavigationController.tsx +23 -8
  109. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  110. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  111. package/src/i18n/FireCMSi18nProvider.tsx +50 -4
  112. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  113. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  114. package/src/internal/useBuildDataSource.ts +37 -15
  115. package/src/internal/useBuildSideEntityController.tsx +20 -5
  116. package/src/locales/de.ts +7 -2
  117. package/src/locales/en.ts +7 -2
  118. package/src/locales/es.ts +7 -2
  119. package/src/locales/fr.ts +7 -2
  120. package/src/locales/hi.ts +7 -2
  121. package/src/locales/it.ts +7 -2
  122. package/src/locales/pl.ts +735 -0
  123. package/src/locales/pt.ts +7 -2
  124. package/src/preview/PropertyPreview.tsx +12 -0
  125. package/src/preview/components/ReferencePreview.tsx +7 -3
  126. package/src/preview/components/StorageThumbnail.tsx +24 -2
  127. package/src/preview/index.ts +1 -0
  128. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  129. package/src/routes/FireCMSRoute.tsx +54 -27
  130. package/src/types/collections.ts +34 -0
  131. package/src/types/datasource.ts +67 -4
  132. package/src/types/entities.ts +24 -1
  133. package/src/types/entity_actions.tsx +6 -0
  134. package/src/types/entity_callbacks.ts +24 -0
  135. package/src/types/navigation.ts +35 -5
  136. package/src/types/plugins.tsx +13 -0
  137. package/src/types/properties.ts +17 -0
  138. package/src/types/side_entity_controller.tsx +7 -0
  139. package/src/types/translations.ts +6 -0
  140. package/src/util/__tests__/collections.test.ts +32 -0
  141. package/src/util/__tests__/urls.test.ts +131 -0
  142. package/src/util/collections.ts +1 -1
  143. package/src/util/entities.ts +16 -1
  144. package/src/util/entity_cache.ts +18 -0
  145. package/src/util/geopoint.ts +81 -0
  146. package/src/util/index.ts +2 -0
  147. package/src/util/navigation_blocking.ts +45 -0
  148. package/src/util/navigation_from_path.ts +47 -7
  149. package/src/util/navigation_utils.ts +244 -4
  150. package/src/util/parent_references_from_path.ts +45 -4
  151. package/src/util/paths.ts +40 -3
  152. package/src/util/permissions.ts +22 -10
  153. package/src/util/previews.ts +8 -1
  154. package/src/util/resolutions.ts +8 -0
  155. package/src/util/urls.ts +52 -0
@@ -10,7 +10,16 @@ import {
10
10
  import { Link, useNavigate } from "react-router-dom";
11
11
  import { CMSAnalyticsEvent, NavigationEntry, NavigationResult } from "../types";
12
12
  import { IconForView } from "../util";
13
- import { cls, IconButton, Menu, MenuItem, MoreVertIcon, Tooltip } from "@firecms/ui";
13
+ import {
14
+ cls,
15
+ KeyboardDoubleArrowLeftIcon,
16
+ KeyboardDoubleArrowRightIcon,
17
+ Menu,
18
+ MenuItem,
19
+ MoreVertIcon,
20
+ Tooltip,
21
+ Typography
22
+ } from "@firecms/ui";
14
23
  import { useAnalyticsController } from "../hooks/useAnalyticsController";
15
24
  import { DrawerNavigationGroup } from "./DrawerNavigationGroup";
16
25
  import { FireCMSLogo } from "../components";
@@ -37,6 +46,14 @@ export function DefaultDrawer({
37
46
 
38
47
  const [adminMenuOpen, setAdminMenuOpen] = React.useState(false);
39
48
 
49
+ const scrollRef = React.useRef<HTMLDivElement>(null);
50
+ const [scrolled, setScrolled] = React.useState(false);
51
+ const handleScroll = () => {
52
+ if (scrollRef.current) {
53
+ setScrolled(scrollRef.current.scrollTop > 0);
54
+ }
55
+ };
56
+
40
57
  const analyticsController = useAnalyticsController();
41
58
  const navigation = useNavigationController();
42
59
  const { t } = useTranslation();
@@ -59,6 +76,8 @@ export function DefaultDrawer({
59
76
  // Collapsible groups state - using "drawer" namespace for independent state from home page
60
77
  const { isGroupCollapsed, toggleGroupCollapsed } = useCollapsedGroups(groupsWithoutAdmin, "drawer");
61
78
 
79
+ const drawerVisuallyOpen = drawerOpen || drawerHovered;
80
+
62
81
  const onItemClick = (view: NavigationEntry) => {
63
82
  const eventName: CMSAnalyticsEvent = view.type === "collection"
64
83
  ? "drawer_navigate_to_collection"
@@ -69,56 +88,66 @@ export function DefaultDrawer({
69
88
  };
70
89
 
71
90
  return (
72
- <>
73
- <div className={cls("flex flex-col h-full relative flex-grow w-full", className)} style={style}>
74
-
75
- <DrawerLogo logo={logo} />
76
-
77
- <div className={"mt-4 flex-grow overflow-scroll no-scrollbar"}
78
- style={{
79
- maskImage: "linear-gradient(to bottom, transparent 0, black 20px, black calc(100% - 20px), transparent 100%)",
80
- }}>
81
-
82
- {groupsWithoutAdmin.map((group) => {
83
- const entriesInGroup = Object.values(navigationEntries).filter(e => e.group === group);
84
- return (
85
- <DrawerNavigationGroup
86
- key={`drawer_group_${group}`}
87
- group={group}
88
- entries={entriesInGroup}
89
- collapsed={isGroupCollapsed(group)}
90
- onToggleCollapsed={() => toggleGroupCollapsed(group)}
91
- drawerOpen={drawerOpen}
92
- tooltipsOpen={tooltipsOpen}
93
- adminMenuOpen={adminMenuOpen}
94
- onItemClick={onItemClick}
95
- />
96
- );
97
- })}
91
+ <div role="navigation" aria-label="Main navigation"
92
+ className={cls("flex flex-col h-full relative grow w-full", className)} style={style}>
98
93
 
99
- </div>
94
+ <DrawerLogo logo={logo} />
95
+
96
+ <div
97
+ ref={scrollRef}
98
+ onScroll={handleScroll}
99
+ className={"flex-grow min-h-0 overflow-y-auto overflow-x-hidden no-scrollbar px-2"}
100
+ style={{
101
+ maskImage: scrolled
102
+ ? "linear-gradient(to bottom, transparent 0, black 20px, black calc(100% - 20px), transparent 100%)"
103
+ : "linear-gradient(to bottom, black 0, black calc(100% - 20px), transparent 100%)"
104
+ }}>
105
+
106
+ {groupsWithoutAdmin.map((group) => {
107
+ const entriesInGroup = Object.values(navigationEntries).filter(e => e.group === group);
108
+ return (
109
+ <DrawerNavigationGroup
110
+ key={`drawer_group_${group}`}
111
+ group={group}
112
+ entries={entriesInGroup}
113
+ collapsed={isGroupCollapsed(group)}
114
+ onToggleCollapsed={() => toggleGroupCollapsed(group)}
115
+ drawerOpen={drawerVisuallyOpen}
116
+ tooltipsOpen={tooltipsOpen}
117
+ adminMenuOpen={adminMenuOpen}
118
+ onItemClick={onItemClick}
119
+ />
120
+ );
121
+ })}
100
122
 
101
- {adminViews.length > 0 && <Menu
123
+ </div>
124
+
125
+ {adminViews.length > 0 && <div className={"shrink-0 px-4"}>
126
+ <Menu
102
127
  side={"right"}
103
128
  open={adminMenuOpen}
104
129
  onOpenChange={setAdminMenuOpen}
105
130
  trigger={
106
- <IconButton
107
- shape={"square"}
108
- className={"m-4 text-surface-900 dark:text-white w-fit"}>
109
- <Tooltip title={"Admin"}
110
- open={tooltipsOpen}
111
- side={"right"} sideOffset={28}>
112
- <MoreVertIcon />
113
- </Tooltip>
114
- {drawerOpen && <div
131
+ <div
132
+ className={cls(
133
+ "flex flex-row items-center rounded-lg cursor-pointer w-full",
134
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-800 transition-colors duration-150 h-[30px]"
135
+ )}>
136
+ <div
137
+ className={"shrink-0 flex items-center justify-center w-[44px] h-[30px] text-surface-500 dark:text-surface-400"}>
138
+ <Tooltip title={"Admin"}
139
+ open={drawerVisuallyOpen || adminMenuOpen ? false : tooltipsOpen}
140
+ side={"right"} sideOffset={28}>
141
+ <MoreVertIcon size={"small"} />
142
+ </Tooltip>
143
+ </div>
144
+ {drawerVisuallyOpen && <div
115
145
  className={cls(
116
- drawerOpen ? "opacity-100" : "opacity-0 hidden",
117
- "mx-4 font-inherit text-inherit"
146
+ "font-semibold text-[11px] uppercase tracking-wider text-surface-400"
118
147
  )}>
119
- ADMIN
148
+ {t("admin")}
120
149
  </div>}
121
- </IconButton>}
150
+ </div>}
122
151
  >
123
152
  {adminViews.map((entry) =>
124
153
  <MenuItem
@@ -131,46 +160,103 @@ export function DefaultDrawer({
131
160
  {t(entry.name)}
132
161
  </MenuItem>)}
133
162
 
134
- </Menu>}
135
- </div>
163
+ </Menu>
164
+ </div>}
136
165
 
137
- </>
166
+ <DrawerToggle />
167
+ </div>
138
168
  );
139
169
  }
140
170
 
141
171
  /**
142
- * This is the logo displayed in the drawer
143
- * It expands when the drawer is open.
172
+ * Collapse/expand toggle rendered at the bottom of the drawer.
173
+ * Uses double-chevron icons to indicate direction. Reads the drawer state
174
+ * from {@link useApp} so it can be dropped into any drawer.
144
175
  *
145
- * @param logo
176
+ * @group Core
177
+ */
178
+ export function DrawerToggle() {
179
+ const {
180
+ drawerOpen,
181
+ drawerHovered,
182
+ openDrawer,
183
+ closeDrawer
184
+ } = useApp();
185
+ const isExpanded = drawerOpen;
186
+ const isHovered = drawerHovered && !drawerOpen;
187
+ const showFullContent = isExpanded || isHovered;
188
+
189
+ return (
190
+ <div className="shrink-0 mt-auto px-4 pt-0.5 pb-2">
191
+ <Tooltip
192
+ title={isExpanded ? "Collapse" : "Expand"}
193
+ side="right"
194
+ sideOffset={12}
195
+ open={isHovered ? false : undefined}
196
+ >
197
+ <div
198
+ className={cls(
199
+ "flex flex-row items-center rounded-lg cursor-pointer",
200
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-800",
201
+ "transition-colors duration-150 h-[30px]"
202
+ )}
203
+ role="button"
204
+ tabIndex={0}
205
+ aria-expanded={isExpanded}
206
+ aria-label={isExpanded ? "Collapse" : "Expand"}
207
+ onClick={() => isExpanded ? closeDrawer() : openDrawer()}
208
+ onKeyDown={(e) => {
209
+ if (e.key === "Enter" || e.key === " ") {
210
+ e.preventDefault();
211
+ isExpanded ? closeDrawer() : openDrawer();
212
+ }
213
+ }}
214
+ >
215
+ <div className="shrink-0 flex items-center justify-center w-[44px] h-[30px] text-surface-500 dark:text-surface-400">
216
+ {isExpanded
217
+ ? <KeyboardDoubleArrowLeftIcon size={"small"} />
218
+ : <KeyboardDoubleArrowRightIcon size={"small"} />}
219
+ </div>
220
+ <div className={cls(
221
+ "overflow-hidden transition-all duration-200 ease-in-out",
222
+ showFullContent ? "opacity-100 w-auto" : "opacity-0 w-0"
223
+ )}>
224
+ <Typography
225
+ variant="body2"
226
+ className="text-surface-500 dark:text-surface-400 select-none whitespace-nowrap"
227
+ >
228
+ {isExpanded ? "Collapse" : "Expand"}
229
+ </Typography>
230
+ </div>
231
+ </div>
232
+ </Tooltip>
233
+ </div>
234
+ );
235
+ }
146
236
 
237
+ /**
238
+ * This is the logo displayed in the drawer.
239
+ * A compact logo aligned to the left of the drawer header.
240
+ *
241
+ * @param logo
147
242
  */
148
243
  export function DrawerLogo({ logo }: {
149
244
  logo?: string;
150
245
  }) {
151
246
 
152
247
  const navigation = useNavigationController();
153
- const { drawerOpen } = useApp();
154
- return <div
155
- style={{
156
- transition: "padding 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms",
157
- padding: drawerOpen ? "32px 144px 0px 24px" : "72px 12px 0px 12px"
158
- }}
159
- className={cls("cursor-pointer rounded ml-3 mr-1")}>
160
-
248
+ return <div className="flex flex-row items-center shrink-0 pt-4 pb-0 px-2">
161
249
  <Tooltip title={"Home"}
162
- sideOffset={20}
163
- side="right">
250
+ sideOffset={20}
251
+ side="right">
164
252
  <Link
165
- className={"block"}
253
+ className={"shrink-0 flex items-center justify-center w-[56px] h-[40px]"}
166
254
  to={navigation.basePath}>
167
255
  {logo
168
256
  ? <img src={logo}
169
- alt="Logo"
170
- className={cls("max-w-full max-h-full transition-all object-contain",
171
- drawerOpen ? "w-[96px] h-[96px]" : "w-[32px] h-[32px]")} />
172
- : <FireCMSLogo />}
173
-
257
+ alt="Logo"
258
+ className={"w-[28px] h-[28px] object-contain"} />
259
+ : <FireCMSLogo width={"28px"} height={"28px"} />}
174
260
  </Link>
175
261
  </Tooltip>
176
262
  </div>;
@@ -62,43 +62,41 @@ export function DrawerNavigationGroup({
62
62
  const { t } = useTranslation();
63
63
  return (
64
64
  <div
65
- className={"bg-surface-50 dark:bg-surface-800/30 my-4 rounded-lg ml-3 mr-1"}
65
+ className={"my-2 mx-2 flex flex-col"}
66
66
  key={`drawer_group_${group}`}
67
67
  >
68
68
  {/* Group Header */}
69
- {drawerOpen ? (
70
- <div
71
- className="pl-4 pr-2 py-2 flex flex-row items-center cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-700/50 rounded-t-lg transition-colors"
72
- onClick={onToggleCollapsed}
73
- >
74
- <ExpandMoreIcon
75
- size={"smallest"}
76
- className={cls(
77
- "text-surface-500 dark:text-surface-400 transition-transform duration-200 mr-1",
78
- collapsed ? "-rotate-90" : "rotate-0"
79
- )}
80
- />
81
- <Typography
82
- variant={"caption"}
83
- color={"secondary"}
84
- className="font-medium flex-grow line-clamp-1"
85
- >
86
- {(group && group !== "__default__" ? group : t("views_group")).toUpperCase()}
87
- </Typography>
88
- {headerActions && (
89
- <div onClick={(e) => e.stopPropagation()}>
90
- {headerActions}
91
- </div>
69
+ <div
70
+ className={cls("pl-3 pr-2 py-0.5 flex flex-row items-center transition-colors",
71
+ drawerOpen ? "cursor-pointer hover:bg-surface-100 dark:hover:bg-surface-800/40 rounded-lg" : "opacity-0 invisible pointer-events-none"
72
+ )}
73
+ onClick={drawerOpen ? onToggleCollapsed : undefined}
74
+ >
75
+ <ExpandMoreIcon
76
+ size={"smallest"}
77
+ className={cls(
78
+ "text-surface-400 dark:text-surface-400 transition-transform duration-200 mr-1",
79
+ collapsed ? "-rotate-90" : "rotate-0"
92
80
  )}
93
- </div>
94
- ) : (
95
- <div className="w-full" />
96
- )}
81
+ />
82
+ <Typography
83
+ variant={"caption"}
84
+ color={"secondary"}
85
+ className="font-semibold text-[11px] uppercase tracking-wider flex-grow line-clamp-1 text-surface-400 dark:text-surface-400"
86
+ >
87
+ {(group && group !== "__default__" ? group : t("views_group"))}
88
+ </Typography>
89
+ {headerActions && (
90
+ <div onClick={(e) => e.stopPropagation()}>
91
+ {headerActions}
92
+ </div>
93
+ )}
94
+ </div>
97
95
 
98
96
  {/* Collapsible Content */}
99
97
  <div
100
98
  className={cls(
101
- "overflow-hidden transition-all duration-200 ease-in-out",
99
+ "transition-all duration-200 ease-in-out overflow-hidden rounded-lg",
102
100
  collapsed ? "max-h-0 opacity-0" : "max-h-[2000px] opacity-100"
103
101
  )}
104
102
  >
@@ -22,7 +22,7 @@ export function DrawerNavigationItem({
22
22
  }) {
23
23
 
24
24
  const iconWrap = <div
25
- className={"text-text-secondary dark:text-text-secondary-dark"}>
25
+ className={"shrink-0 flex items-center justify-center w-[44px] h-[30px] text-surface-500 dark:text-text-secondary-dark group-hover/nav:text-primary transition-colors duration-150"}>
26
26
  {icon}
27
27
  </div>;
28
28
 
@@ -33,14 +33,12 @@ export function DrawerNavigationItem({
33
33
  width: "100%",
34
34
  transition: drawerOpen ? "width 150ms ease-in" : undefined
35
35
  }}
36
- className={({ isActive }: any) => cls("rounded-lg truncate",
37
- "hover:bg-surface-accent-300 hover:bg-opacity-75 hover:bg-surface-accent-300/75 dark:hover:bg-surface-accent-800 dark:hover:bg-opacity-75 dark:hover:bg-surface-accent-800/75 text-text-primary dark:text-surface-200 hover:text-surface-900 hover:dark:text-white hover:bg-surface-accent-300/75 dark:hover:bg-surface-accent-800/75",
38
- "flex flex-row items-center mr-8",
39
- // "transition-all ease-in-out delay-100 duration-300",
40
- // drawerOpen ? "w-full" : "w-18",
41
- drawerOpen ? "pl-4 h-10" : "pl-4 h-9",
42
- "font-semibold text-xs",
43
- isActive ? "bg-surface-accent-200 bg-opacity-60 dark:bg-surface-800 dark:bg-opacity-50 bg-surface-accent-200/60 dark:bg-surface-800/50" : ""
36
+ className={({ isActive }: { isActive: boolean }) => cls("rounded-lg truncate group/nav",
37
+ "hover:bg-primary/5 dark:hover:bg-primary/5 text-surface-700 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white",
38
+ "flex flex-row items-center",
39
+ drawerOpen ? "pr-4 h-[30px]" : "h-[30px]",
40
+ "font-medium text-[13px]",
41
+ isActive ? "bg-primary/8 dark:bg-primary/10 text-primary dark:text-primary [&_div]:text-primary" : ""
44
42
  )}
45
43
  to={url}
46
44
  >
@@ -49,11 +47,11 @@ export function DrawerNavigationItem({
49
47
 
50
48
  <div
51
49
  className={cls(
52
- "text-text-primary dark:text-surface-200",
50
+ "text-surface-700 dark:text-surface-300",
53
51
  drawerOpen ? "opacity-100" : "opacity-0 hidden",
54
- "ml-4 font-inherit"
52
+ "font-inherit truncate space-x-2"
55
53
  )}>
56
- {name.toUpperCase()}
54
+ {name}
57
55
  </div>
58
56
  </NavLink>
59
57
  </div>;
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useMemo, useState } from "react";
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  Entity,
4
4
  EntityCollection,
@@ -12,7 +12,9 @@ import {
12
12
 
13
13
  import { CircularProgressCenter, EntityCollectionView, EntityView, ErrorBoundary } from "../components";
14
14
  import {
15
+ buildSubcollectionPathSegments,
15
16
  canEditEntity,
17
+ encodeEntityId,
16
18
  removeInitialAndTrailingSlashes,
17
19
  resolveCollection,
18
20
  resolveDefaultSelectedView,
@@ -27,7 +29,7 @@ import {
27
29
  useLargeLayout
28
30
  } from "../hooks";
29
31
  import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
30
- import { getEntityFromMemoryCache } from "../util/entity_cache";
32
+ import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
31
33
  import { EntityForm, EntityFormProps } from "../form";
32
34
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
33
35
  import { EntityJsonPreview } from "../components/EntityJsonPreview";
@@ -41,6 +43,8 @@ export type OnUpdateParams = {
41
43
  entity: Entity<any>,
42
44
  status: EntityStatus,
43
45
  path: string,
46
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
47
+ pathSegments?: string[],
44
48
  entityId?: string;
45
49
  selectedTab?: string;
46
50
  collection: EntityCollection<any>
@@ -70,6 +74,12 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
70
74
  * The navigation path to the entity.
71
75
  */
72
76
  fullIdPath?: string;
77
+ /**
78
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
79
+ * Forwarded to the datasource, and extended for any subcollection rendered inside this
80
+ * view, so a parent entity id containing "/" stays unambiguous.
81
+ */
82
+ pathSegments?: string[];
73
83
  collection: EntityCollection<M>;
74
84
  entityId?: string;
75
85
  databaseId?: string;
@@ -100,6 +110,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
100
110
  dataLoadingError
101
111
  } = useEntityFetch<M, USER>({
102
112
  path: props.path,
113
+ pathSegments: props.pathSegments,
103
114
  entityId: entityId,
104
115
  collection: props.collection,
105
116
  databaseId: props.databaseId,
@@ -107,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
107
118
  });
108
119
 
109
120
  const initialDirtyValues = entityId
110
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
121
+ ? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
111
122
  : getEntityFromMemoryCache(props.path + "#new");
112
123
 
113
124
  const authController = useAuthController();
@@ -119,7 +130,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
119
130
  if (status === "new" || status === "copy") {
120
131
  return true;
121
132
  } else {
122
- return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null) : undefined;
133
+ return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null, props.pathSegments) : undefined;
123
134
  }
124
135
  }, [authController, entity, status]);
125
136
 
@@ -145,6 +156,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
145
156
  export function EntityEditViewInner<M extends Record<string, any>>({
146
157
  path,
147
158
  fullIdPath,
159
+ pathSegments,
148
160
  entityId,
149
161
  selectedTab: selectedTabProp,
150
162
  collection,
@@ -193,6 +205,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
193
205
  entityId,
194
206
  parentCollectionIds,
195
207
  path,
208
+ pathSegments,
196
209
  status,
197
210
  collection,
198
211
  context,
@@ -246,6 +259,47 @@ export function EntityEditViewInner<M extends Record<string, any>>({
246
259
 
247
260
  const authController = useAuthController();
248
261
 
262
+ // Track which tabs have been visited so we keep them mounted (preserving
263
+ // their state) without eagerly mounting tabs that were never opened.
264
+ const mountedTabsRef = useRef<Set<string>>(new Set());
265
+ if (selectedTab) {
266
+ mountedTabsRef.current.add(selectedTab);
267
+ }
268
+
269
+ // Memoize the read-only fallback form context so it is not recreated on
270
+ // every render when there is no real formContext (read-only entity views).
271
+ const readOnlyFormContext = useMemo<FormContext | undefined>(() => {
272
+ if (formContext) return undefined; // real formContext takes precedence
273
+ if (!entityId) return undefined;
274
+ const formexStub = createFormexStub<M>(usedEntity?.values ?? {} as M);
275
+ return {
276
+ entityId,
277
+ disabled: false,
278
+ openEntityMode: layout,
279
+ status: status,
280
+ values: usedEntity?.values ?? {},
281
+ setFieldValue: (key: string, value: any) => {
282
+ throw new Error("You can't update values in read only mode");
283
+ },
284
+ save: () => {
285
+ throw new Error("You can't save in read only mode");
286
+ },
287
+ collection: resolveCollection<M>({
288
+ collection,
289
+ path,
290
+ entityId,
291
+ values: usedEntity?.values ?? {},
292
+ previousValues: usedEntity?.values ?? {},
293
+ propertyConfigs: customizationController.propertyConfigs,
294
+ authController
295
+ }),
296
+ path,
297
+ entity: usedEntity,
298
+ savingError: undefined,
299
+ formex: formexStub
300
+ };
301
+ }, [formContext, entityId, layout, status, usedEntity, collection, path, customizationController.propertyConfigs, authController]);
302
+
249
303
  const customViewsView: React.ReactNode[] | undefined = customViews && resolvedEntityViews
250
304
  .filter(e => !e.includeActions)
251
305
  .map((customView) => {
@@ -262,38 +316,18 @@ export function EntityEditViewInner<M extends Record<string, any>>({
262
316
  return null;
263
317
  }
264
318
 
265
- const formexStub = createFormexStub<M>(usedEntity?.values ?? {} as M);
266
- const usedFormContext: FormContext = formContext ?? {
267
- entityId,
268
- disabled: false,
269
- openEntityMode: layout,
270
- status: status,
271
- values: usedEntity?.values ?? {},
272
- setFieldValue: (key: string, value: any) => {
273
- throw new Error("You can't update values in read only mode");
274
- },
275
- save: () => {
276
- throw new Error("You can't save in read only mode");
277
- },
278
- collection: resolveCollection<M>({
279
- collection,
280
- path,
281
- entityId,
282
- values: usedEntity?.values ?? {},
283
- previousValues: usedEntity?.values ?? {},
284
- propertyConfigs: customizationController.propertyConfigs,
285
- authController
286
- }),
287
- path,
288
- entity: usedEntity,
289
- savingError: undefined,
290
- formex: formexStub
291
- };
319
+ // Only mount tabs that are active or have been visited before.
320
+ const isActive = selectedTab === customView.key;
321
+ if (!isActive && !mountedTabsRef.current.has(customView.key)) {
322
+ return null;
323
+ }
324
+
325
+ const usedFormContext: FormContext = formContext ?? readOnlyFormContext!;
292
326
 
293
327
  return <div
294
328
  className={cls(defaultBorderMixin,
295
329
  "relative flex-1 w-full h-full overflow-auto",
296
- { "hidden": selectedTab !== customView.key }
330
+ { "hidden": !isActive }
297
331
  )}
298
332
  key={`custom_view_${customView.key}`}
299
333
  role="tabpanel">
@@ -311,7 +345,8 @@ export function EntityEditViewInner<M extends Record<string, any>>({
311
345
 
312
346
  const globalLoading = dataLoading && !usedEntity;
313
347
 
314
- const jsonView = <div
348
+ // Only mount the JSON view once its tab has been selected at least once.
349
+ const jsonView = (selectedTab === JSON_TAB_VALUE || mountedTabsRef.current.has(JSON_TAB_VALUE)) ? <div
315
350
  className={cls("relative flex-1 h-full overflow-auto w-full",
316
351
  { "hidden": selectedTab !== JSON_TAB_VALUE })}
317
352
  key={"json_view"}
@@ -320,12 +355,17 @@ export function EntityEditViewInner<M extends Record<string, any>>({
320
355
  <EntityJsonPreview
321
356
  values={formContext?.values ?? entity?.values ?? {}} />
322
357
  </ErrorBoundary>
323
- </div>;
358
+ </div> : null;
324
359
 
325
360
  const subCollectionsViews = subcollections && subcollections.map((subcollection) => {
326
361
  const subcollectionId = subcollection.id ?? subcollection.path;
362
+ // `newFullPath` addresses the datasource, so the parent id stays raw.
327
363
  const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
328
- const newFullIdPath = fullIdPath ? `${fullIdPath}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
364
+ // `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
365
+ const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
366
+ // Only extendable when we were given real segments — deriving the parent chain
367
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
368
+ const newPathSegments = buildSubcollectionPathSegments(pathSegments, usedEntity?.id, subcollection.path);
329
369
 
330
370
  if (selectedTab !== subcollectionId) return null;
331
371
  return (
@@ -340,6 +380,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
340
380
  (usedEntity && newFullPath
341
381
  ? <EntityCollectionView
342
382
  fullPath={newFullPath}
383
+ pathSegments={newPathSegments}
343
384
  fullIdPath={newFullIdPath}
344
385
  parentCollectionIds={[...parentCollectionIds, collection.id]}
345
386
  isSubCollection={true}
@@ -389,6 +430,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
389
430
  fullIdPath={fullIdPath}
390
431
  collection={collection}
391
432
  path={path}
433
+ pathSegments={pathSegments}
392
434
  entityId={entityId ?? usedEntity?.id}
393
435
  onValuesModified={onValuesModified}
394
436
  entity={entity}
@@ -39,6 +39,7 @@ import { ErrorTooltip } from "../components/ErrorTooltip";
39
39
  export function EntityEditViewFormActions({
40
40
  collection,
41
41
  path,
42
+ pathSegments,
42
43
  entity,
43
44
  layout,
44
45
  savingError,
@@ -63,8 +64,8 @@ export function EntityEditViewFormActions({
63
64
  const customEntityActions = (collection.entityActions ?? [])
64
65
  .map(action => resolveEntityAction(action, customizationController.entityActions))
65
66
  .filter(Boolean) as EntityAction[];
66
- const createEnabled = canCreateEntity(collection, authController, path, null);
67
- const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity) : false;
67
+ const createEnabled = canCreateEntity(collection, authController, path, null, pathSegments);
68
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity, entity.pathSegments ?? pathSegments) : false;
68
69
  const actions: EntityAction[] = [];
69
70
  if (createEnabled)
70
71
  actions.push(copyEntityAction);