@firecms/core 3.3.0 → 3.4.0-canary.0ef7442

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 (124) 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 +7 -0
  7. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  8. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  9. package/dist/components/EntityPreview.d.ts +3 -1
  10. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  11. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  12. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  13. package/dist/components/common/table_url_params.d.ts +24 -0
  14. package/dist/components/common/useDataSourceTableController.d.ts +7 -1
  15. package/dist/components/common/useTableSearchHelper.d.ts +7 -1
  16. package/dist/core/DefaultDrawer.d.ts +10 -3
  17. package/dist/core/EntityEditView.d.ts +7 -1
  18. package/dist/core/field_configs.d.ts +1 -1
  19. package/dist/form/EntityForm.d.ts +6 -1
  20. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  21. package/dist/form/index.d.ts +1 -0
  22. package/dist/hooks/data/save.d.ts +1 -1
  23. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  24. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  25. package/dist/index.es.js +2915 -1330
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +2913 -1328
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/locales/pl.d.ts +2 -0
  30. package/dist/preview/index.d.ts +1 -0
  31. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  32. package/dist/types/collections.d.ts +24 -0
  33. package/dist/types/datasource.d.ts +59 -4
  34. package/dist/types/entities.d.ts +21 -1
  35. package/dist/types/properties.d.ts +17 -1
  36. package/dist/types/side_entity_controller.d.ts +6 -0
  37. package/dist/types/translations.d.ts +6 -0
  38. package/dist/util/__tests__/collections.test.d.ts +1 -0
  39. package/dist/util/__tests__/urls.test.d.ts +1 -0
  40. package/dist/util/collections.d.ts +1 -1
  41. package/dist/util/entities.d.ts +1 -0
  42. package/dist/util/geopoint.d.ts +22 -0
  43. package/dist/util/index.d.ts +2 -0
  44. package/dist/util/navigation_blocking.d.ts +22 -0
  45. package/dist/util/navigation_from_path.d.ts +17 -0
  46. package/dist/util/navigation_utils.d.ts +23 -1
  47. package/dist/util/parent_references_from_path.d.ts +1 -0
  48. package/dist/util/urls.d.ts +22 -0
  49. package/package.json +17 -10
  50. package/src/app/Scaffold.tsx +34 -44
  51. package/src/app/useApp.tsx +1 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  53. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  54. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  55. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  56. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  57. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
  58. package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  60. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  61. package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
  62. package/src/components/EntityPreview.tsx +41 -40
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
  64. package/src/components/ReferenceWidget.tsx +1 -1
  65. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  66. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  67. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  68. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  69. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  70. package/src/components/common/table_url_params.ts +172 -0
  71. package/src/components/common/useDataSourceTableController.tsx +104 -159
  72. package/src/components/common/useTableSearchHelper.ts +21 -2
  73. package/src/contexts/SnackbarProvider.tsx +14 -1
  74. package/src/core/DefaultDrawer.tsx +150 -64
  75. package/src/core/DrawerNavigationGroup.tsx +27 -29
  76. package/src/core/DrawerNavigationItem.tsx +10 -12
  77. package/src/core/EntityEditView.tsx +78 -32
  78. package/src/core/EntitySidePanel.tsx +2 -0
  79. package/src/core/field_configs.tsx +15 -0
  80. package/src/form/EntityForm.tsx +9 -3
  81. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  82. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  83. package/src/form/index.tsx +1 -0
  84. package/src/hooks/data/delete.ts +2 -0
  85. package/src/hooks/data/save.ts +2 -0
  86. package/src/hooks/data/useCollectionFetch.tsx +13 -0
  87. package/src/hooks/data/useEntityFetch.tsx +18 -0
  88. package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
  89. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  90. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  91. package/src/internal/useBuildDataSource.ts +25 -4
  92. package/src/internal/useBuildSideEntityController.tsx +8 -1
  93. package/src/locales/de.ts +7 -2
  94. package/src/locales/en.ts +7 -2
  95. package/src/locales/es.ts +7 -2
  96. package/src/locales/fr.ts +7 -2
  97. package/src/locales/hi.ts +7 -2
  98. package/src/locales/it.ts +7 -2
  99. package/src/locales/pl.ts +735 -0
  100. package/src/locales/pt.ts +7 -2
  101. package/src/preview/PropertyPreview.tsx +12 -0
  102. package/src/preview/components/StorageThumbnail.tsx +24 -2
  103. package/src/preview/index.ts +1 -0
  104. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  105. package/src/routes/FireCMSRoute.tsx +47 -25
  106. package/src/types/collections.ts +26 -0
  107. package/src/types/datasource.ts +59 -4
  108. package/src/types/entities.ts +24 -1
  109. package/src/types/properties.ts +17 -0
  110. package/src/types/side_entity_controller.tsx +6 -0
  111. package/src/types/translations.ts +6 -0
  112. package/src/util/__tests__/collections.test.ts +32 -0
  113. package/src/util/__tests__/urls.test.ts +131 -0
  114. package/src/util/collections.ts +1 -1
  115. package/src/util/entities.ts +16 -1
  116. package/src/util/geopoint.ts +81 -0
  117. package/src/util/index.ts +2 -0
  118. package/src/util/navigation_blocking.ts +45 -0
  119. package/src/util/navigation_from_path.ts +41 -6
  120. package/src/util/navigation_utils.ts +40 -2
  121. package/src/util/parent_references_from_path.ts +14 -4
  122. package/src/util/previews.ts +8 -1
  123. package/src/util/resolutions.ts +8 -0
  124. 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,
@@ -13,6 +13,7 @@ import {
13
13
  import { CircularProgressCenter, EntityCollectionView, EntityView, ErrorBoundary } from "../components";
14
14
  import {
15
15
  canEditEntity,
16
+ encodeEntityId,
16
17
  removeInitialAndTrailingSlashes,
17
18
  resolveCollection,
18
19
  resolveDefaultSelectedView,
@@ -70,6 +71,12 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
70
71
  * The navigation path to the entity.
71
72
  */
72
73
  fullIdPath?: string;
74
+ /**
75
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
76
+ * Forwarded to the datasource, and extended for any subcollection rendered inside this
77
+ * view, so a parent entity id containing "/" stays unambiguous.
78
+ */
79
+ pathSegments?: string[];
73
80
  collection: EntityCollection<M>;
74
81
  entityId?: string;
75
82
  databaseId?: string;
@@ -100,6 +107,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
100
107
  dataLoadingError
101
108
  } = useEntityFetch<M, USER>({
102
109
  path: props.path,
110
+ pathSegments: props.pathSegments,
103
111
  entityId: entityId,
104
112
  collection: props.collection,
105
113
  databaseId: props.databaseId,
@@ -145,6 +153,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
145
153
  export function EntityEditViewInner<M extends Record<string, any>>({
146
154
  path,
147
155
  fullIdPath,
156
+ pathSegments,
148
157
  entityId,
149
158
  selectedTab: selectedTabProp,
150
159
  collection,
@@ -246,6 +255,47 @@ export function EntityEditViewInner<M extends Record<string, any>>({
246
255
 
247
256
  const authController = useAuthController();
248
257
 
258
+ // Track which tabs have been visited so we keep them mounted (preserving
259
+ // their state) without eagerly mounting tabs that were never opened.
260
+ const mountedTabsRef = useRef<Set<string>>(new Set());
261
+ if (selectedTab) {
262
+ mountedTabsRef.current.add(selectedTab);
263
+ }
264
+
265
+ // Memoize the read-only fallback form context so it is not recreated on
266
+ // every render when there is no real formContext (read-only entity views).
267
+ const readOnlyFormContext = useMemo<FormContext | undefined>(() => {
268
+ if (formContext) return undefined; // real formContext takes precedence
269
+ if (!entityId) return undefined;
270
+ const formexStub = createFormexStub<M>(usedEntity?.values ?? {} as M);
271
+ return {
272
+ entityId,
273
+ disabled: false,
274
+ openEntityMode: layout,
275
+ status: status,
276
+ values: usedEntity?.values ?? {},
277
+ setFieldValue: (key: string, value: any) => {
278
+ throw new Error("You can't update values in read only mode");
279
+ },
280
+ save: () => {
281
+ throw new Error("You can't save in read only mode");
282
+ },
283
+ collection: resolveCollection<M>({
284
+ collection,
285
+ path,
286
+ entityId,
287
+ values: usedEntity?.values ?? {},
288
+ previousValues: usedEntity?.values ?? {},
289
+ propertyConfigs: customizationController.propertyConfigs,
290
+ authController
291
+ }),
292
+ path,
293
+ entity: usedEntity,
294
+ savingError: undefined,
295
+ formex: formexStub
296
+ };
297
+ }, [formContext, entityId, layout, status, usedEntity, collection, path, customizationController.propertyConfigs, authController]);
298
+
249
299
  const customViewsView: React.ReactNode[] | undefined = customViews && resolvedEntityViews
250
300
  .filter(e => !e.includeActions)
251
301
  .map((customView) => {
@@ -262,38 +312,18 @@ export function EntityEditViewInner<M extends Record<string, any>>({
262
312
  return null;
263
313
  }
264
314
 
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
- };
315
+ // Only mount tabs that are active or have been visited before.
316
+ const isActive = selectedTab === customView.key;
317
+ if (!isActive && !mountedTabsRef.current.has(customView.key)) {
318
+ return null;
319
+ }
320
+
321
+ const usedFormContext: FormContext = formContext ?? readOnlyFormContext!;
292
322
 
293
323
  return <div
294
324
  className={cls(defaultBorderMixin,
295
325
  "relative flex-1 w-full h-full overflow-auto",
296
- { "hidden": selectedTab !== customView.key }
326
+ { "hidden": !isActive }
297
327
  )}
298
328
  key={`custom_view_${customView.key}`}
299
329
  role="tabpanel">
@@ -311,7 +341,8 @@ export function EntityEditViewInner<M extends Record<string, any>>({
311
341
 
312
342
  const globalLoading = dataLoading && !usedEntity;
313
343
 
314
- const jsonView = <div
344
+ // Only mount the JSON view once its tab has been selected at least once.
345
+ const jsonView = (selectedTab === JSON_TAB_VALUE || mountedTabsRef.current.has(JSON_TAB_VALUE)) ? <div
315
346
  className={cls("relative flex-1 h-full overflow-auto w-full",
316
347
  { "hidden": selectedTab !== JSON_TAB_VALUE })}
317
348
  key={"json_view"}
@@ -320,12 +351,25 @@ export function EntityEditViewInner<M extends Record<string, any>>({
320
351
  <EntityJsonPreview
321
352
  values={formContext?.values ?? entity?.values ?? {}} />
322
353
  </ErrorBoundary>
323
- </div>;
354
+ </div> : null;
324
355
 
325
356
  const subCollectionsViews = subcollections && subcollections.map((subcollection) => {
326
357
  const subcollectionId = subcollection.id ?? subcollection.path;
358
+ // `newFullPath` addresses the datasource, so the parent id stays raw.
327
359
  const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
328
- const newFullIdPath = fullIdPath ? `${fullIdPath}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
360
+ // `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
361
+ const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
362
+ // The subcollection sits under this entity, so its segments are ours plus this
363
+ // entity's id (kept whole, slashes and all) plus the subcollection's own path.
364
+ // Only extendable when we were given real segments — deriving the parent chain
365
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
366
+ const newPathSegments = usedEntity && pathSegments
367
+ ? [
368
+ ...pathSegments,
369
+ usedEntity.id,
370
+ ...removeInitialAndTrailingSlashes(subcollection.path).split("/")
371
+ ]
372
+ : undefined;
329
373
 
330
374
  if (selectedTab !== subcollectionId) return null;
331
375
  return (
@@ -340,6 +384,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
340
384
  (usedEntity && newFullPath
341
385
  ? <EntityCollectionView
342
386
  fullPath={newFullPath}
387
+ pathSegments={newPathSegments}
343
388
  fullIdPath={newFullIdPath}
344
389
  parentCollectionIds={[...parentCollectionIds, collection.id]}
345
390
  isSubCollection={true}
@@ -389,6 +434,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
389
434
  fullIdPath={fullIdPath}
390
435
  collection={collection}
391
436
  path={path}
437
+ pathSegments={pathSegments}
392
438
  entityId={entityId ?? usedEntity?.id}
393
439
  onValuesModified={onValuesModified}
394
440
  entity={entity}
@@ -25,6 +25,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
25
25
  path,
26
26
  entityId,
27
27
  fullIdPath,
28
+ pathSegments,
28
29
  formProps,
29
30
  } = props;
30
31
 
@@ -116,6 +117,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
116
117
  <EntityEditView
117
118
  {...props}
118
119
  fullIdPath={fullIdPath}
120
+ pathSegments={pathSegments}
119
121
  layout={"side_panel"}
120
122
  collection={collection as EntityCollection}
121
123
  parentCollectionIds={parentCollectionIds}
@@ -6,6 +6,7 @@ import {
6
6
  ArrayOfReferencesFieldBinding,
7
7
  BlockFieldBinding,
8
8
  DateTimeFieldBinding,
9
+ GeopointFieldBinding,
9
10
  KeyValueFieldBinding,
10
11
  MapFieldBinding,
11
12
  MultiSelectFieldBinding,
@@ -34,6 +35,7 @@ import {
34
35
  ListAltIcon,
35
36
  ListIcon,
36
37
  MailIcon,
38
+ LocationOnIcon,
37
39
  NumbersIcon, PersonIcon,
38
40
  RepeatIcon,
39
41
  ScheduleIcon,
@@ -273,6 +275,17 @@ export const DEFAULT_FIELD_CONFIGS: Record<string, PropertyConfig<any>> = {
273
275
  Field: DateTimeFieldBinding
274
276
  }
275
277
  },
278
+ geopoint: {
279
+ key: "geopoint",
280
+ name: "Geopoint",
281
+ description: "Latitude and longitude pair",
282
+ Icon: LocationOnIcon,
283
+ color: "#0ea5e9",
284
+ property: {
285
+ dataType: "geopoint",
286
+ Field: GeopointFieldBinding
287
+ }
288
+ },
276
289
  group: {
277
290
  key: "group",
278
291
  name: "Group",
@@ -419,6 +432,8 @@ export function getDefaultFieldId(property: Property | ResolvedProperty) {
419
432
  return "switch";
420
433
  } else if (property.dataType === "date") {
421
434
  return "date_time";
435
+ } else if (property.dataType === "geopoint") {
436
+ return "geopoint";
422
437
  } else if (property.dataType === "reference") {
423
438
  return "reference";
424
439
  }