@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.
- package/dist/components/HomePage/HomePageDnD.d.ts +2 -1
- package/dist/components/PropertyCollectionView.d.ts +23 -0
- package/dist/components/UserDisplay.d.ts +7 -0
- package/dist/components/VirtualTable/fields/VirtualTableUserSelect.d.ts +12 -0
- package/dist/contexts/InternalUserManagementContext.d.ts +3 -0
- package/dist/core/EntityEditView.d.ts +10 -4
- package/dist/core/FireCMS.d.ts +0 -1
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +5 -2
- package/dist/form/components/LocalChangesMenu.d.ts +11 -0
- package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +12 -0
- package/dist/form/index.d.ts +2 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/useCollapsedGroups.d.ts +9 -0
- package/dist/hooks/useInternalUserManagementController.d.ts +12 -0
- package/dist/index.es.js +1983 -650
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1981 -648
- package/dist/index.umd.js.map +1 -1
- package/dist/preview/components/UserPreview.d.ts +8 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/types/collections.d.ts +13 -0
- package/dist/types/entities.d.ts +5 -1
- package/dist/types/firecms.d.ts +15 -0
- package/dist/types/firecms_context.d.ts +16 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/internal_user_management.d.ts +20 -0
- package/dist/types/plugins.d.ts +2 -0
- package/dist/types/properties.d.ts +41 -6
- package/dist/types/property_config.d.ts +1 -1
- package/dist/types/user.d.ts +1 -1
- package/dist/util/collections.d.ts +1 -0
- package/dist/util/entity_cache.d.ts +6 -1
- package/dist/util/make_properties_editable.d.ts +1 -2
- package/dist/util/objects.d.ts +1 -0
- package/dist/util/useStorageUploadController.d.ts +1 -0
- package/package.json +6 -6
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +47 -47
- package/src/components/EntityCollectionTable/PropertyTableCell.tsx +12 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +6 -1
- package/src/components/EntityView.tsx +29 -40
- package/src/components/ErrorView.tsx +1 -1
- package/src/components/HomePage/DefaultHomePage.tsx +21 -34
- package/src/components/HomePage/HomePageDnD.tsx +143 -83
- package/src/components/HomePage/RenameGroupDialog.tsx +9 -3
- package/src/components/PropertyCollectionView.tsx +329 -0
- package/src/components/PropertyConfigBadge.tsx +2 -2
- package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +2 -1
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -2
- package/src/components/UserDisplay.tsx +55 -0
- package/src/components/VirtualTable/fields/VirtualTableUserSelect.tsx +99 -0
- package/src/components/common/useColumnsIds.tsx +1 -8
- package/src/contexts/InternalUserManagementContext.tsx +4 -0
- package/src/core/EntityEditView.tsx +27 -14
- package/src/core/EntityEditViewFormActions.tsx +33 -18
- package/src/core/EntitySidePanel.tsx +9 -3
- package/src/core/FireCMS.tsx +22 -13
- package/src/core/field_configs.tsx +15 -1
- package/src/form/EntityForm.tsx +173 -42
- package/src/form/EntityFormActions.tsx +30 -15
- package/src/form/PropertyFieldBinding.tsx +4 -0
- package/src/form/components/ErrorFocus.tsx +22 -29
- package/src/form/components/LocalChangesMenu.tsx +144 -0
- package/src/form/field_bindings/UserSelectFieldBinding.tsx +94 -0
- package/src/form/index.tsx +5 -1
- package/src/hooks/index.tsx +3 -0
- package/src/hooks/useBrowserTitleAndIcon.tsx +1 -1
- package/src/hooks/useBuildNavigationController.tsx +104 -31
- package/src/hooks/useCollapsedGroups.ts +64 -0
- package/src/hooks/useFireCMSContext.tsx +6 -2
- package/src/hooks/useInternalUserManagementController.tsx +16 -0
- package/src/preview/PropertyPreview.tsx +8 -0
- package/src/preview/components/ReferencePreview.tsx +4 -2
- package/src/preview/components/UserPreview.tsx +27 -0
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/ArrayPropertyPreview.tsx +1 -1
- package/src/preview/property_previews/MapPropertyPreview.tsx +2 -2
- package/src/preview/property_previews/NumberPropertyPreview.tsx +2 -2
- package/src/types/collections.ts +14 -0
- package/src/types/entities.ts +7 -1
- package/src/types/firecms.tsx +16 -0
- package/src/types/firecms_context.tsx +17 -0
- package/src/types/index.ts +1 -0
- package/src/types/internal_user_management.ts +24 -0
- package/src/types/plugins.tsx +3 -0
- package/src/types/properties.ts +45 -6
- package/src/types/property_config.tsx +1 -0
- package/src/types/user.ts +1 -1
- package/src/util/collections.ts +8 -0
- package/src/util/createFormexStub.tsx +4 -0
- package/src/util/entities.ts +1 -1
- package/src/util/entity_cache.ts +72 -53
- package/src/util/join_collections.ts +3 -3
- package/src/util/make_properties_editable.ts +0 -22
- package/src/util/objects.ts +40 -2
- 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
|
+
}
|
package/src/form/index.tsx
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export
|
|
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";
|
package/src/hooks/index.tsx
CHANGED
|
@@ -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,
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
...
|
|
275
|
+
...groupsFromMappings,
|
|
276
|
+
...additionalGroups
|
|
286
277
|
];
|
|
287
278
|
|
|
288
|
-
|
|
289
|
-
|
|
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:
|
|
289
|
+
onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
|
|
296
290
|
};
|
|
297
|
-
}, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
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 <
|
|
35
|
+
return <ErrorBoundary>
|
|
36
|
+
<ReferencePreviewInternal {...props} />
|
|
37
|
+
</ErrorBoundary>;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
function ReferencePreviewInternal({
|