@firecms/core 3.0.0-rc.1 → 3.0.0-rc.3

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 (96) hide show
  1. package/dist/components/HomePage/HomePageDnD.d.ts +2 -1
  2. package/dist/components/PropertyCollectionView.d.ts +23 -0
  3. package/dist/components/UserDisplay.d.ts +7 -0
  4. package/dist/components/VirtualTable/fields/VirtualTableUserSelect.d.ts +12 -0
  5. package/dist/contexts/InternalUserManagementContext.d.ts +3 -0
  6. package/dist/core/EntityEditView.d.ts +10 -4
  7. package/dist/core/FireCMS.d.ts +0 -1
  8. package/dist/core/field_configs.d.ts +1 -1
  9. package/dist/form/EntityForm.d.ts +5 -2
  10. package/dist/form/components/LocalChangesMenu.d.ts +11 -0
  11. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +12 -0
  12. package/dist/form/index.d.ts +2 -1
  13. package/dist/hooks/index.d.ts +2 -0
  14. package/dist/hooks/useCollapsedGroups.d.ts +9 -0
  15. package/dist/hooks/useInternalUserManagementController.d.ts +12 -0
  16. package/dist/index.es.js +1983 -650
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/index.umd.js +1981 -648
  19. package/dist/index.umd.js.map +1 -1
  20. package/dist/preview/components/UserPreview.d.ts +8 -0
  21. package/dist/preview/index.d.ts +1 -0
  22. package/dist/types/collections.d.ts +13 -0
  23. package/dist/types/entities.d.ts +5 -1
  24. package/dist/types/firecms.d.ts +15 -0
  25. package/dist/types/firecms_context.d.ts +16 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/internal_user_management.d.ts +20 -0
  28. package/dist/types/plugins.d.ts +2 -0
  29. package/dist/types/properties.d.ts +41 -6
  30. package/dist/types/property_config.d.ts +1 -1
  31. package/dist/types/user.d.ts +1 -1
  32. package/dist/util/collections.d.ts +1 -0
  33. package/dist/util/entity_cache.d.ts +6 -1
  34. package/dist/util/make_properties_editable.d.ts +1 -2
  35. package/dist/util/objects.d.ts +1 -0
  36. package/dist/util/useStorageUploadController.d.ts +1 -0
  37. package/package.json +6 -6
  38. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +47 -47
  39. package/src/components/EntityCollectionTable/PropertyTableCell.tsx +12 -0
  40. package/src/components/EntityCollectionView/EntityCollectionView.tsx +6 -1
  41. package/src/components/EntityView.tsx +29 -40
  42. package/src/components/ErrorView.tsx +1 -1
  43. package/src/components/HomePage/DefaultHomePage.tsx +21 -34
  44. package/src/components/HomePage/HomePageDnD.tsx +143 -83
  45. package/src/components/HomePage/RenameGroupDialog.tsx +9 -3
  46. package/src/components/PropertyCollectionView.tsx +329 -0
  47. package/src/components/PropertyConfigBadge.tsx +2 -2
  48. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +2 -1
  49. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -2
  50. package/src/components/UserDisplay.tsx +55 -0
  51. package/src/components/VirtualTable/fields/VirtualTableUserSelect.tsx +99 -0
  52. package/src/components/common/useColumnsIds.tsx +1 -8
  53. package/src/contexts/InternalUserManagementContext.tsx +4 -0
  54. package/src/core/EntityEditView.tsx +27 -14
  55. package/src/core/EntityEditViewFormActions.tsx +33 -18
  56. package/src/core/EntitySidePanel.tsx +9 -3
  57. package/src/core/FireCMS.tsx +22 -13
  58. package/src/core/field_configs.tsx +15 -1
  59. package/src/form/EntityForm.tsx +173 -42
  60. package/src/form/EntityFormActions.tsx +30 -15
  61. package/src/form/PropertyFieldBinding.tsx +4 -0
  62. package/src/form/components/ErrorFocus.tsx +22 -29
  63. package/src/form/components/LocalChangesMenu.tsx +144 -0
  64. package/src/form/field_bindings/UserSelectFieldBinding.tsx +94 -0
  65. package/src/form/index.tsx +5 -1
  66. package/src/hooks/index.tsx +3 -0
  67. package/src/hooks/useBrowserTitleAndIcon.tsx +1 -1
  68. package/src/hooks/useBuildNavigationController.tsx +104 -31
  69. package/src/hooks/useCollapsedGroups.ts +64 -0
  70. package/src/hooks/useFireCMSContext.tsx +6 -2
  71. package/src/hooks/useInternalUserManagementController.tsx +16 -0
  72. package/src/preview/PropertyPreview.tsx +8 -0
  73. package/src/preview/components/ReferencePreview.tsx +4 -2
  74. package/src/preview/components/UserPreview.tsx +27 -0
  75. package/src/preview/index.ts +1 -0
  76. package/src/preview/property_previews/ArrayPropertyPreview.tsx +1 -1
  77. package/src/preview/property_previews/MapPropertyPreview.tsx +2 -2
  78. package/src/preview/property_previews/NumberPropertyPreview.tsx +2 -2
  79. package/src/types/collections.ts +14 -0
  80. package/src/types/entities.ts +7 -1
  81. package/src/types/firecms.tsx +16 -0
  82. package/src/types/firecms_context.tsx +17 -0
  83. package/src/types/index.ts +1 -0
  84. package/src/types/internal_user_management.ts +24 -0
  85. package/src/types/plugins.tsx +3 -0
  86. package/src/types/properties.ts +45 -6
  87. package/src/types/property_config.tsx +1 -0
  88. package/src/types/user.ts +1 -1
  89. package/src/util/collections.ts +8 -0
  90. package/src/util/createFormexStub.tsx +4 -0
  91. package/src/util/entities.ts +1 -1
  92. package/src/util/entity_cache.ts +72 -53
  93. package/src/util/join_collections.ts +3 -3
  94. package/src/util/make_properties_editable.ts +0 -22
  95. package/src/util/objects.ts +40 -2
  96. package/src/util/useStorageUploadController.tsx +71 -34
@@ -0,0 +1,144 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ Button,
4
+ CancelIcon,
5
+ CheckIcon,
6
+ defaultBorderMixin,
7
+ Dialog,
8
+ DialogActions,
9
+ DialogContent,
10
+ DialogTitle,
11
+ KeyboardArrowDownIcon,
12
+ Menu,
13
+ MenuItem,
14
+ Typography,
15
+ VisibilityIcon,
16
+ WarningIcon
17
+ } from "@firecms/ui";
18
+ import { FormexController } from "@firecms/formex";
19
+ import { useSnackbarController } from "../../hooks";
20
+ import { mergeDeep } from "../../util";
21
+ import { flattenKeys, removeEntityFromCache } from "../../util/entity_cache";
22
+ import { ResolvedProperties } from "../../types";
23
+ import { PropertyCollectionView } from "../../components/PropertyCollectionView";
24
+
25
+ interface LocalChangesMenuProps<M extends object> {
26
+ cacheKey: string;
27
+ localChangesData: Partial<M>;
28
+ formex: FormexController<M>;
29
+ onClearLocalChanges?: () => void;
30
+ properties: ResolvedProperties<M>;
31
+ }
32
+
33
+ export function LocalChangesMenu<M extends object>({
34
+ localChangesData,
35
+ formex,
36
+ onClearLocalChanges,
37
+ cacheKey,
38
+ properties
39
+ }: LocalChangesMenuProps<M>) {
40
+
41
+ const snackbarController = useSnackbarController();
42
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
43
+ const [open, setOpen] = useState(false);
44
+
45
+ const handleOpenMenu = () => setOpen(true);
46
+ const handleCloseMenu = () => setOpen(false);
47
+
48
+ const handlePreview = () => {
49
+ setPreviewDialogOpen(true);
50
+ handleCloseMenu();
51
+ };
52
+
53
+ const handleApply = () => {
54
+ const mergedValues = mergeDeep(formex.values, localChangesData);
55
+ const touched = { ...formex.touched };
56
+ const previewKeys = flattenKeys(localChangesData);
57
+ previewKeys.forEach((key) => {
58
+ touched[key] = true;
59
+ });
60
+
61
+ formex.setTouched(touched);
62
+ formex.setValues(mergedValues);
63
+ snackbarController.open({
64
+ type: "info",
65
+ message: "Local changes applied to the form"
66
+ });
67
+ handleCloseMenu();
68
+ onClearLocalChanges?.();
69
+ };
70
+
71
+ const handleDiscard = () => {
72
+ removeEntityFromCache(cacheKey);
73
+ snackbarController.open({
74
+ type: "info",
75
+ message: "Local changes discarded"
76
+ });
77
+ handleCloseMenu();
78
+ onClearLocalChanges?.();
79
+ };
80
+
81
+ return (
82
+ <>
83
+ <Menu
84
+ trigger={
85
+ <Button
86
+ size={"small"}
87
+ className={
88
+ "font-semibold text-xs rounded-full px-4 py-1 bg-yellow-200 dark:bg-yellow-900 hover:bg-yellow-300 dark:hover:bg-yellow-800 text-yellow-800 dark:text-yellow-200"
89
+ }
90
+ onClick={handleOpenMenu}
91
+ >
92
+ <WarningIcon size={"smallest"} className={"mr-1 text-yellow-600 dark:text-yellow-400"}/>
93
+ Unsaved Local changes
94
+ <KeyboardArrowDownIcon size={"smallest"}/>
95
+ </Button>
96
+ }
97
+ open={open}
98
+ onOpenChange={setOpen}
99
+ >
100
+ <div className={"max-w-xs px-4 py-4 text-sm text-gray-700 dark:text-gray-300"}>
101
+ This document was edited locally and has unsaved changes. These local changes will be lost if you
102
+ don't apply them.
103
+ </div>
104
+ <MenuItem dense onClick={handlePreview}><VisibilityIcon size={"small"}/>Preview Changes</MenuItem>
105
+ <MenuItem dense onClick={handleApply}><CheckIcon size={"small"}/>Apply Changes</MenuItem>
106
+ <MenuItem dense onClick={handleDiscard}><CancelIcon size={"small"}/>Discard Local Changes</MenuItem>
107
+ </Menu>
108
+
109
+ <Dialog
110
+ open={previewDialogOpen}
111
+ onOpenChange={setPreviewDialogOpen}
112
+ maxWidth={"4xl"}
113
+ >
114
+ <DialogTitle variant={"h6"}>Preview Local Changes</DialogTitle>
115
+ <DialogContent className={"my-4"}>
116
+ <Typography variant={"body2"} className={"mb-4"}>
117
+ These are the local changes that will be applied to the form.
118
+ </Typography>
119
+ <div className={`border rounded-lg ${defaultBorderMixin}`} style={{
120
+ maxHeight: 520,
121
+ overflow: "auto"
122
+ }}>
123
+ <div className="p-4">
124
+ <PropertyCollectionView data={localChangesData}
125
+ properties={properties as ResolvedProperties}/>
126
+ </div>
127
+ </div>
128
+ </DialogContent>
129
+ <DialogActions>
130
+ <Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
131
+ <Button
132
+ variant={"filled"}
133
+ onClick={() => {
134
+ handleApply();
135
+ setPreviewDialogOpen(false);
136
+ }}
137
+ >
138
+ Apply changes
139
+ </Button>
140
+ </DialogActions>
141
+ </Dialog>
142
+ </>
143
+ );
144
+ }
@@ -0,0 +1,94 @@
1
+ import React, { useCallback } from "react";
2
+
3
+ import { FieldProps, User } from "../../types";
4
+ import { FieldHelperText, LabelWithIcon } from "../components";
5
+ import { getIconForProperty } from "../../util";
6
+ import { CloseIcon, cls, IconButton, Select, SelectItem } from "@firecms/ui";
7
+ import { PropertyIdCopyTooltip } from "../../components";
8
+ import { useInternalUserManagementController } from "../../hooks";
9
+ import { UserDisplay } from "../../components/UserDisplay";
10
+
11
+ type UserSelectProps = FieldProps<string>;
12
+
13
+ /**
14
+ * Field binding for selecting a user from the internal user management system.
15
+ * Renders a select dropdown with user information including name and email.
16
+ *
17
+ * This is one of the internal components that get mapped natively inside forms
18
+ * and tables to the specified properties.
19
+ * @group Form fields
20
+ */
21
+ export function UserSelectFieldBinding({
22
+ propertyKey,
23
+ value,
24
+ setValue,
25
+ error,
26
+ showError,
27
+ disabled,
28
+ autoFocus,
29
+ touched,
30
+ property,
31
+ includeDescription,
32
+ size = "large"
33
+ }: UserSelectProps) {
34
+
35
+ const { users, getUser } = useInternalUserManagementController();
36
+
37
+ const handleClearClick = useCallback((e: React.MouseEvent) => {
38
+ e.stopPropagation();
39
+ e.preventDefault();
40
+ setValue(null);
41
+ }, [setValue]);
42
+
43
+ return (
44
+ <>
45
+ <Select
46
+ value={value !== undefined && value != null ? value.toString() : ""}
47
+ disabled={disabled}
48
+ size={size}
49
+ fullWidth={true}
50
+ position="item-aligned"
51
+ inputClassName={cls("w-full")}
52
+ label={
53
+ <PropertyIdCopyTooltip propertyKey={propertyKey}>
54
+ <LabelWithIcon
55
+ icon={getIconForProperty(property, "small")}
56
+ required={property.validation?.required}
57
+ title={property.name}
58
+ className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3.5 my-0"}
59
+ />
60
+ </PropertyIdCopyTooltip>}
61
+ endAdornment={
62
+ property.clearable && !disabled && value && <IconButton
63
+ size="small"
64
+ onClick={handleClearClick}>
65
+ <CloseIcon size={"small"}/>
66
+ </IconButton>
67
+ }
68
+ onValueChange={(updatedValue: string) => {
69
+ const newValue = updatedValue || null;
70
+ return setValue(newValue);
71
+ }}
72
+ renderValue={(userId: string) => {
73
+ const user = getUser(userId);
74
+ return <UserDisplay user={user} />;
75
+ }}
76
+ >
77
+ {users && users.map((user) => {
78
+ return <SelectItem
79
+ key={user.uid}
80
+ value={user.uid}>
81
+ <UserDisplay user={user} />
82
+ </SelectItem>
83
+ })}
84
+ </Select>
85
+
86
+ <FieldHelperText includeDescription={includeDescription}
87
+ showError={showError}
88
+ error={error}
89
+ disabled={disabled}
90
+ property={property}/>
91
+
92
+ </>
93
+ );
94
+ }
@@ -1,4 +1,8 @@
1
- export * from "./EntityForm";
1
+ export {
2
+ EntityForm,
3
+ yupToFormErrors,
4
+ } from "./EntityForm";
5
+ export type { EntityFormProps } from "./EntityForm";
2
6
 
3
7
  export { SelectFieldBinding } from "./field_bindings/SelectFieldBinding";
4
8
  export { MultiSelectFieldBinding } from "./field_bindings/MultiSelectFieldBinding";
@@ -18,6 +18,9 @@ export * from "./useSnackbarController";
18
18
  export * from "./useModeController";
19
19
  export * from "./useClipboard";
20
20
  export * from "./useLargeLayout";
21
+ export * from "./useCollapsedGroups";
22
+
23
+ export * from "./useInternalUserManagementController";
21
24
 
22
25
  export * from "./useReferenceDialog";
23
26
  export * from "./useBrowserTitleAndIcon";
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
 
3
- const fireCMSLogo = "data:image/png;base64,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";
3
+ const fireCMSLogo = "data:image/png;base64,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";
4
4
 
5
5
  /**
6
6
  * Internal hook to handle the browser title and icon
@@ -149,26 +149,10 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
149
149
  const allPluginGroups = plugins?.flatMap(plugin => plugin.homePage?.navigationEntries ? plugin.homePage.navigationEntries.map(e => e.name) : []) ?? [];
150
150
  const pluginGroups = [...new Set(allPluginGroups)];
151
151
 
152
- const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
153
- if (!plugins) {
154
- return;
155
- }
156
- // remove all groups that have no entries
157
- const filteredEntries = entries.filter(entry => entry.entries.length > 0);
158
- if (plugins.some(plugin => plugin.homePage?.onNavigationEntriesUpdate)) {
159
- plugins.forEach(plugin => {
160
- if (plugin.homePage?.onNavigationEntriesUpdate) {
161
- plugin.homePage.onNavigationEntriesUpdate(filteredEntries);
162
- }
163
- });
164
- }
165
-
166
- }, [plugins]);
167
-
168
- const computeTopNavigation = useCallback((collections: EntityCollection[], views: CMSView[], adminViews: CMSView[], viewsOrder?: string[]): NavigationResult => {
152
+ const computeTopNavigation = useCallback((collections: EntityCollection[], views: CMSView[], adminViews: CMSView[], viewsOrder?: string[], navigationGroupMappingsOverride?: NavigationGroupMapping[], onNavigationEntriesUpdateCallback?: (entries: NavigationGroupMapping[]) => void): NavigationResult => {
169
153
 
170
154
  const finalNavigationGroupMappings: NavigationGroupMapping[] = computeNavigationGroups({
171
- navigationGroupMappings: navigationGroupMappings,
155
+ navigationGroupMappings: navigationGroupMappingsOverride ?? navigationGroupMappings,
172
156
  collections,
173
157
  views,
174
158
  plugins: plugins
@@ -209,7 +193,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
209
193
  ...(views ?? []).reduce((acc, view) => {
210
194
  if (view.hideFromNavigation) return acc;
211
195
 
212
- const pathKey = Array.isArray(view.path) ? view.path[0] : view.path;
196
+ const pathKey = view.path;
213
197
  let groupName = getGroup(view); // Initial group
214
198
 
215
199
  if (finalNavigationGroupMappings) {
@@ -237,7 +221,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
237
221
  ...(adminViews ?? []).reduce((acc, view) => {
238
222
  if (view.hideFromNavigation) return acc;
239
223
 
240
- const pathKey = Array.isArray(view.path) ? view.path[0] : view.path;
224
+ const pathKey = view.path;
241
225
  const groupName = NAVIGATION_ADMIN_GROUP_NAME;
242
226
 
243
227
  acc.push({
@@ -280,21 +264,62 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
280
264
  .map(e => e.group)
281
265
  .filter(Boolean) as string[];
282
266
 
267
+ // Preserve order from finalNavigationGroupMappings (persisted order)
268
+ const groupsFromMappings = finalNavigationGroupMappings.map(g => g.name);
269
+
270
+ // Add any additional groups not in mappings
271
+ const additionalGroups = collectedGroupsFromEntries.filter(g => !groupsFromMappings.includes(g));
272
+
283
273
  const allDefinedGroups = [
284
274
  ...(pluginGroups ?? []),
285
- ...collectedGroupsFromEntries
275
+ ...groupsFromMappings,
276
+ ...additionalGroups
286
277
  ];
287
278
 
288
- const uniqueGroups = [...new Set(allDefinedGroups)]
289
- .sort((a, b) => groupOrderValue(a) - groupOrderValue(b));
279
+ // Remove duplicates while preserving order, then separate admin to the end
280
+ const uniqueGroupsArray = [...new Set(allDefinedGroups)];
281
+ const adminGroups = uniqueGroupsArray.filter(g => g === NAVIGATION_ADMIN_GROUP_NAME);
282
+ const nonAdminGroups = uniqueGroupsArray.filter(g => g !== NAVIGATION_ADMIN_GROUP_NAME);
283
+ const uniqueGroups = [...nonAdminGroups, ...adminGroups];
290
284
 
291
285
  return {
292
286
  allowDragAndDrop: plugins?.some(plugin => plugin.homePage?.allowDragAndDrop) ?? false,
293
287
  navigationEntries,
294
288
  groups: uniqueGroups,
295
- onNavigationEntriesUpdate: onNavigationEntriesOrderUpdate,
289
+ onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
296
290
  };
297
- }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, onNavigationEntriesOrderUpdate]);
291
+ }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
292
+
293
+ const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
294
+ if (!plugins) {
295
+ return;
296
+ }
297
+ // remove all groups that have no entries
298
+ const filteredEntries = entries.filter(entry => entry.entries.length > 0);
299
+
300
+ // Immediately update the local topLevelNavigation with new mappings
301
+ if (collectionsRef.current && viewsRef.current) {
302
+ const updatedNav = computeTopNavigation(
303
+ collectionsRef.current,
304
+ viewsRef.current,
305
+ adminViewsRef.current ?? [],
306
+ viewsOrder,
307
+ filteredEntries,
308
+ onNavigationEntriesOrderUpdate
309
+ );
310
+ setTopLevelNavigation(updatedNav);
311
+ }
312
+
313
+ // Then persist to backend
314
+ if (plugins.some(plugin => plugin.homePage?.onNavigationEntriesUpdate)) {
315
+ plugins.forEach(plugin => {
316
+ if (plugin.homePage?.onNavigationEntriesUpdate) {
317
+ plugin.homePage.onNavigationEntriesUpdate(filteredEntries);
318
+ }
319
+ });
320
+ }
321
+
322
+ }, [plugins, computeTopNavigation, viewsOrder]);
298
323
 
299
324
  const refreshNavigation = useCallback(async () => {
300
325
 
@@ -312,7 +337,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
312
337
  ]
313
338
  );
314
339
 
315
- const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder);
340
+ const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
316
341
 
317
342
  let shouldUpdateTopLevelNav = false;
318
343
  if (!areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
@@ -457,8 +482,9 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
457
482
  [fullCollectionPath]);
458
483
 
459
484
  const urlPathToDataPath = useCallback((path: string): string => {
460
- if (path.startsWith(fullCollectionPath))
461
- return path.replace(fullCollectionPath, "");
485
+ const decodedPath = decodeURIComponent(path);
486
+ if (decodedPath.startsWith(fullCollectionPath))
487
+ return decodedPath.replace(fullCollectionPath, "");
462
488
  throw Error("Expected path starting with " + fullCollectionPath);
463
489
  }, [fullCollectionPath]);
464
490
 
@@ -716,6 +742,7 @@ function computeNavigationGroups({
716
742
 
717
743
  let result = navigationGroupMappings;
718
744
 
745
+ // Merge plugin navigation entries
719
746
  result = plugins ? plugins?.reduce((acc, plugin) => {
720
747
  if (plugin.homePage?.navigationEntries) {
721
748
  plugin.homePage.navigationEntries.forEach((entry) => {
@@ -738,8 +765,54 @@ function computeNavigationGroups({
738
765
  return acc;
739
766
  }, [...(result ?? [])] as NavigationGroupMapping[]) : result;
740
767
 
768
+ // Track all entries that are already assigned to groups
769
+ const assignedEntries = new Set<string>();
770
+ if (result) {
771
+ result.forEach(group => {
772
+ group.entries.forEach(entry => assignedEntries.add(entry));
773
+ });
774
+ }
775
+
776
+ // Find collections and views that are NOT in any persisted group
777
+ const unassignedGroupMap: Record<string, string[]> = {};
778
+
779
+ // Check collections
780
+ (collections ?? []).forEach(collection => {
781
+ const entry = collection.id ?? collection.path;
782
+ if (!assignedEntries.has(entry)) {
783
+ const groupName = getGroup(collection);
784
+ if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
785
+ unassignedGroupMap[groupName].push(entry);
786
+ }
787
+ });
788
+
789
+ // Check views
790
+ (views ?? []).forEach(view => {
791
+ const entry = view.path;
792
+ if (!assignedEntries.has(entry)) {
793
+ const groupName = getGroup(view);
794
+ if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
795
+ unassignedGroupMap[groupName].push(entry);
796
+ }
797
+ });
798
+
799
+ // Merge unassigned entries into existing groups or create new groups
800
+ Object.entries(unassignedGroupMap).forEach(([groupName, entries]) => {
801
+ if (result) {
802
+ const existingGroup = result.find(g => g.name === groupName);
803
+ if (existingGroup) {
804
+ existingGroup.entries.push(...entries);
805
+ } else {
806
+ result.push({
807
+ name: groupName,
808
+ entries
809
+ });
810
+ }
811
+ }
812
+ });
813
+
741
814
  if (!result) {
742
- // Convert views and collections to navigation group mappings, grouped by their group name
815
+ // No persisted data at all - create from scratch
743
816
  result = [];
744
817
  const groupMap: Record<string, string[]> = {};
745
818
 
@@ -754,12 +827,12 @@ function computeNavigationGroups({
754
827
  // Add views
755
828
  (views ?? []).forEach(view => {
756
829
  const name = getGroup(view);
757
- const entry = Array.isArray(view.path) ? view.path[0] : view.path;
830
+ const entry = view.path;
758
831
  if (!groupMap[name]) groupMap[name] = [];
759
832
  groupMap[name].push(entry);
760
833
  });
761
834
 
762
- // Convert groupMap to initialGroupMappings array
835
+ // Convert groupMap to result array
763
836
  result = Object.entries(groupMap).map(([name, entries]) => ({
764
837
  name,
765
838
  entries
@@ -0,0 +1,64 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ /**
4
+ * Custom hook for managing collapsed/expanded state of navigation groups
5
+ * with localStorage persistence. Automatically cleans up stale group entries
6
+ * when groups are removed from the navigation.
7
+ */
8
+ export function useCollapsedGroups(groupNames: string[]) {
9
+ // Load collapsed groups from localStorage on mount
10
+ const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>(() => {
11
+ try {
12
+ const stored = localStorage.getItem('firecms-collapsed-groups');
13
+ return stored ? JSON.parse(stored) : {};
14
+ } catch {
15
+ return {};
16
+ }
17
+ });
18
+
19
+ // Save to localStorage whenever collapsedGroups changes
20
+ useEffect(() => {
21
+ try {
22
+ localStorage.setItem('firecms-collapsed-groups', JSON.stringify(collapsedGroups));
23
+ } catch {
24
+ // Silently fail if localStorage is not available
25
+ }
26
+ }, [collapsedGroups]);
27
+
28
+ // Clean up collapsed groups state when groups change - remove entries for groups that no longer exist
29
+ useEffect(() => {
30
+ // Only clean up if we have actual groups loaded (avoid cleaning up during initial load)
31
+ if (groupNames.length === 0) return;
32
+
33
+ const currentGroupNames = new Set(groupNames);
34
+
35
+ setCollapsedGroups(prev => {
36
+ const cleaned = Object.fromEntries(
37
+ Object.entries(prev).filter(([groupName]) => currentGroupNames.has(groupName))
38
+ );
39
+
40
+ // Only update if something actually changed
41
+ const prevKeys = Object.keys(prev);
42
+ const cleanedKeys = Object.keys(cleaned);
43
+
44
+ if (prevKeys.length === cleanedKeys.length && prevKeys.every(key => cleanedKeys.includes(key))) {
45
+ return prev;
46
+ }
47
+
48
+ return cleaned;
49
+ });
50
+ }, [groupNames]);
51
+
52
+ const isGroupCollapsed = useCallback((name: string) => {
53
+ return !!collapsedGroups[name];
54
+ }, [collapsedGroups]);
55
+
56
+ const toggleGroupCollapsed = useCallback((name: string) => {
57
+ setCollapsedGroups(prev => ({ ...prev, [name]: !prev[name] }));
58
+ }, []);
59
+
60
+ return {
61
+ isGroupCollapsed,
62
+ toggleGroupCollapsed
63
+ };
64
+ }
@@ -11,6 +11,7 @@ import { useDialogsController } from "./useDialogsController";
11
11
  import { useCustomizationController } from "./useCustomizationController";
12
12
  import { useAnalyticsController } from "./useAnalyticsController";
13
13
  import React, { useEffect } from "react";
14
+ import { useInternalUserManagementController } from "./useInternalUserManagementController";
14
15
 
15
16
  /**
16
17
  * Hook to retrieve the {@link FireCMSContext}.
@@ -34,6 +35,7 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
34
35
  const dialogsController = useDialogsController();
35
36
  const customizationController = useCustomizationController();
36
37
  const analyticsController = useAnalyticsController();
38
+ const userManagement = useInternalUserManagementController<USER>();
37
39
 
38
40
  const fireCMSContextRef = React.useRef<FireCMSContext<USER, AuthControllerType>>({
39
41
  authController,
@@ -46,7 +48,8 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
46
48
  userConfigPersistence,
47
49
  dialogsController,
48
50
  customizationController,
49
- analyticsController
51
+ analyticsController,
52
+ userManagement
50
53
  });
51
54
 
52
55
  useEffect(() => {
@@ -61,7 +64,8 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
61
64
  userConfigPersistence,
62
65
  dialogsController,
63
66
  customizationController,
64
- analyticsController
67
+ analyticsController,
68
+ userManagement
65
69
  };
66
70
  }, [authController, dialogsController, navigation, sideDialogsController]);
67
71
 
@@ -0,0 +1,16 @@
1
+ import { useContext } from "react";
2
+ import { InternalUserManagement, NavigationController, User } from "../types";
3
+ import { NavigationContext } from "../contexts/NavigationContext";
4
+ import { InternalUserManagementContext } from "../contexts/InternalUserManagementContext";
5
+
6
+ /**
7
+ * Use this hook to get the internal user management of the app.
8
+ * Note that this is different from the user management plugin controller.
9
+ * This controller will be eventually replaced by the one provided
10
+ * by the user management plugin.
11
+ *
12
+ * Use at your own risk!
13
+ *
14
+ * @group Hooks and utilities
15
+ */
16
+ export const useInternalUserManagementController = <USER extends User = User>(): InternalUserManagement<USER> => useContext(InternalUserManagementContext);
@@ -31,6 +31,7 @@ import { DatePreview } from "./components/DatePreview";
31
31
  import { BooleanPreview } from "./components/BooleanPreview";
32
32
  import { NumberPropertyPreview } from "./property_previews/NumberPropertyPreview";
33
33
  import { ErrorView } from "../components";
34
+ import { UserPreview } from "./components/UserPreview";
34
35
 
35
36
  /**
36
37
  * @group Preview components
@@ -97,6 +98,13 @@ export const PropertyPreview = React.memo(function PropertyPreview<T extends CMS
97
98
  previewType={stringProperty.url}/>;
98
99
  } else if (stringProperty.markdown) {
99
100
  content = <Markdown source={value} size={"small"}/>;
101
+ } else if (stringProperty.userSelect) {
102
+ content = <UserPreview
103
+ value={value}
104
+ property={stringProperty}
105
+ propertyKey={propertyKey}
106
+ size={props.size}
107
+ />;
100
108
  } else if (stringProperty.reference) {
101
109
  if (typeof stringProperty.reference.path === "string") {
102
110
  content = <ReferencePreview
@@ -4,7 +4,7 @@ import { Entity, EntityCollection, EntityReference } from "../../types";
4
4
  import { useCustomizationController, useEntityFetch, useNavigationController } from "../../hooks";
5
5
  import { PreviewSize } from "../PropertyPreviewProps";
6
6
  import { Skeleton } from "@firecms/ui";
7
- import { ErrorView } from "../../components";
7
+ import { ErrorBoundary, ErrorView } from "../../components";
8
8
  import { EntityPreview, EntityPreviewContainer } from "../../components/EntityPreview";
9
9
 
10
10
  export type ReferencePreviewProps = {
@@ -32,7 +32,9 @@ export const ReferencePreview = function ReferencePreview(props: ReferencePrevie
32
32
  tooltip={JSON.stringify(reference)}/>
33
33
  </EntityPreviewContainer>;
34
34
  }
35
- return <ReferencePreviewInternal {...props} />;
35
+ return <ErrorBoundary>
36
+ <ReferencePreviewInternal {...props} />
37
+ </ErrorBoundary>;
36
38
  };
37
39
 
38
40
  function ReferencePreviewInternal({