@firecms/core 3.0.0-canary.285 → 3.0.0-canary.287
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/core/EntityEditView.d.ts +8 -2
- package/dist/form/EntityForm.d.ts +1 -1
- package/dist/form/components/LocalChangesMenu.d.ts +11 -0
- package/dist/index.es.js +363 -45
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +361 -43
- package/dist/index.umd.js.map +1 -1
- package/dist/types/collections.d.ts +10 -6
- package/dist/util/collections.d.ts +1 -0
- package/dist/util/entity_cache.d.ts +5 -2
- package/package.json +5 -5
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +5 -2
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +1 -1
- package/src/core/EntityEditView.tsx +20 -8
- package/src/core/EntitySidePanel.tsx +9 -3
- package/src/form/EntityForm.tsx +77 -22
- package/src/form/components/LocalChangesMenu.tsx +157 -0
- package/src/types/collections.ts +10 -6
- package/src/util/collections.ts +8 -0
- package/src/util/createFormexStub.tsx +4 -0
- package/src/util/entity_cache.ts +18 -22
|
@@ -308,12 +308,16 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
|
|
|
308
308
|
*/
|
|
309
309
|
history?: boolean;
|
|
310
310
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
|
|
316
|
-
|
|
311
|
+
* Should local changes be backed up in local storage, to prevent data loss on
|
|
312
|
+
* accidental navigations.
|
|
313
|
+
* - `manual_apply`: When the user navigates back to an entity with local changes,
|
|
314
|
+
* they will be prompted to restore the changes.
|
|
315
|
+
* - `auto_apply`: When the user navigates back to an entity with local changes,
|
|
316
|
+
* the changes will be automatically applied.
|
|
317
|
+
* - `false`: Local changes will not be backed up.
|
|
318
|
+
* Defaults to `manual_apply`.
|
|
319
|
+
*/
|
|
320
|
+
localChangesBackup?: "manual_apply" | "auto_apply" | false;
|
|
317
321
|
}
|
|
318
322
|
/**
|
|
319
323
|
* Parameter passed to the `Actions` prop in the collection configuration.
|
|
@@ -9,3 +9,4 @@ export declare function resolveDefaultSelectedView(defaultSelectedView: string |
|
|
|
9
9
|
* @param permissionsBuilder
|
|
10
10
|
*/
|
|
11
11
|
export declare const applyPermissionsFunctionIfEmpty: (collections: EntityCollection[], permissionsBuilder?: PermissionsBuilder<any, any>) => EntityCollection[];
|
|
12
|
+
export declare function getLocalChangesBackup(collection: EntityCollection): "manual_apply" | "auto_apply";
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* @param data - The data to cache and persist.
|
|
5
5
|
*/
|
|
6
6
|
export declare function saveEntityToCache(path: string, data: object): void;
|
|
7
|
+
export declare function removeEntityFromMemoryCache(path: string): void;
|
|
8
|
+
export declare function saveEntityToMemoryCache(path: string, data: object): void;
|
|
9
|
+
export declare function getEntityFromMemoryCache(path: string): object | undefined;
|
|
10
|
+
export declare function hasEntityInCache(path: string): boolean;
|
|
7
11
|
/**
|
|
8
12
|
* Retrieves an entity from the in-memory cache or `localStorage`.
|
|
9
13
|
* If the entity is not in the cache but exists in `localStorage`, it loads it into the cache.
|
|
@@ -11,8 +15,7 @@ export declare function saveEntityToCache(path: string, data: object): void;
|
|
|
11
15
|
* @param useLocalStorage
|
|
12
16
|
* @returns The cached entity or `undefined` if not found.
|
|
13
17
|
*/
|
|
14
|
-
export declare function getEntityFromCache(path: string
|
|
15
|
-
export declare function hasEntityInCache(path: string): boolean;
|
|
18
|
+
export declare function getEntityFromCache(path: string): object | undefined;
|
|
16
19
|
/**
|
|
17
20
|
* Removes an entity from both the in-memory cache and `localStorage`.
|
|
18
21
|
* @param path - The unique path/key for the entity to remove.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firecms/core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.0.0-canary.
|
|
4
|
+
"version": "3.0.0-canary.287",
|
|
5
5
|
"description": "Awesome Firebase/Firestore-based headless open-source CMS",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/firecmsco"
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"@dnd-kit/core": "^6.3.1",
|
|
54
54
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
55
55
|
"@dnd-kit/sortable": "^10.0.0",
|
|
56
|
-
"@firecms/editor": "^3.0.0-canary.
|
|
57
|
-
"@firecms/formex": "^3.0.0-canary.
|
|
58
|
-
"@firecms/ui": "^3.0.0-canary.
|
|
56
|
+
"@firecms/editor": "^3.0.0-canary.287",
|
|
57
|
+
"@firecms/formex": "^3.0.0-canary.287",
|
|
58
|
+
"@firecms/ui": "^3.0.0-canary.287",
|
|
59
59
|
"@radix-ui/react-portal": "^1.1.9",
|
|
60
60
|
"clsx": "^2.1.1",
|
|
61
61
|
"date-fns": "^3.6.0",
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"dist",
|
|
109
109
|
"src"
|
|
110
110
|
],
|
|
111
|
-
"gitHead": "
|
|
111
|
+
"gitHead": "c6606ddc2309cbdaeacb3103fbb2bb50a20418ce",
|
|
112
112
|
"publishConfig": {
|
|
113
113
|
"access": "public"
|
|
114
114
|
},
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
Tooltip
|
|
15
15
|
} from "@firecms/ui";
|
|
16
16
|
import { useFireCMSContext, useLargeLayout } from "../../hooks";
|
|
17
|
-
import { hasEntityInCache } from "../../util/entity_cache";
|
|
17
|
+
import { getEntityFromCache, hasEntityInCache } from "../../util/entity_cache";
|
|
18
|
+
import { getLocalChangesBackup } from "../../util";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
*
|
|
@@ -79,7 +80,9 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
|
|
|
79
80
|
|
|
80
81
|
const collapsedActions = actions.filter(a => a.collapsed || a.collapsed === undefined);
|
|
81
82
|
const uncollapsedActions = actions.filter(a => a.collapsed === false);
|
|
82
|
-
const
|
|
83
|
+
const enableLocalChangesBackup = collection ? getLocalChangesBackup(collection) : false;
|
|
84
|
+
const hasDraft = enableLocalChangesBackup ? getEntityFromCache(fullPath + "/" + entity.id) : false;
|
|
85
|
+
|
|
83
86
|
return (
|
|
84
87
|
<div
|
|
85
88
|
className={cls(
|
|
@@ -694,7 +694,7 @@ export const EntityCollectionView = React.memo(
|
|
|
694
694
|
className="mt-4"
|
|
695
695
|
>
|
|
696
696
|
<AddIcon/>
|
|
697
|
-
Create your first
|
|
697
|
+
Create your first entry
|
|
698
698
|
</Button>
|
|
699
699
|
</div>
|
|
700
700
|
: <Typography variant={"label"}>No results with the applied filter/sort</Typography>
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
Entity,
|
|
4
4
|
EntityCollection,
|
|
5
5
|
EntityStatus,
|
|
6
|
+
EntityValues,
|
|
6
7
|
FireCMSPlugin,
|
|
7
8
|
FormContext,
|
|
8
9
|
PluginFormActionProps,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
useLargeLayout
|
|
27
28
|
} from "../hooks";
|
|
28
29
|
import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography } from "@firecms/ui";
|
|
29
|
-
import {
|
|
30
|
+
import { getEntityFromMemoryCache } from "../util/entity_cache";
|
|
30
31
|
import { EntityForm, EntityFormProps } from "../form";
|
|
31
32
|
import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
|
|
32
33
|
import { EntityJsonPreview } from "../components/EntityJsonPreview";
|
|
@@ -44,6 +45,13 @@ export type OnUpdateParams = {
|
|
|
44
45
|
collection: EntityCollection<any>
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
export type BarActionsParams = {
|
|
49
|
+
values: object,
|
|
50
|
+
status: EntityStatus,
|
|
51
|
+
path: string,
|
|
52
|
+
entityId?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
47
55
|
export type OnTabChangeParams<M extends Record<string, any>> = {
|
|
48
56
|
path: string;
|
|
49
57
|
entityId?: string;
|
|
@@ -67,11 +75,11 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
|
|
|
67
75
|
copy?: boolean;
|
|
68
76
|
selectedTab?: string;
|
|
69
77
|
parentCollectionIds: string[];
|
|
70
|
-
onValuesModified?: (modified: boolean) => void;
|
|
78
|
+
onValuesModified?: (modified: boolean, values:M) => void;
|
|
71
79
|
onSaved?: (params: OnUpdateParams) => void;
|
|
72
80
|
onTabChange?: (props: OnTabChangeParams<M>) => void;
|
|
73
81
|
layout?: "side_panel" | "full_screen";
|
|
74
|
-
barActions?: React.ReactNode;
|
|
82
|
+
barActions?: (params: BarActionsParams) => React.ReactNode;
|
|
75
83
|
formProps?: Partial<EntityFormProps<M>>,
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -97,11 +105,9 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
|
|
|
97
105
|
useCache: false
|
|
98
106
|
});
|
|
99
107
|
|
|
100
|
-
const enableLocalChangesBackup = props.collection.enableLocalChangesBackup !== undefined ? props.collection.enableLocalChangesBackup : true;
|
|
101
|
-
|
|
102
108
|
const initialDirtyValues = entityId
|
|
103
|
-
?
|
|
104
|
-
:
|
|
109
|
+
? getEntityFromMemoryCache(props.path + "/" + entityId)
|
|
110
|
+
: getEntityFromMemoryCache(props.path + "#new");
|
|
105
111
|
|
|
106
112
|
const authController = useAuthController();
|
|
107
113
|
|
|
@@ -390,6 +396,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
390
396
|
disabled={!canEdit}
|
|
391
397
|
{...formProps}
|
|
392
398
|
onEntityChange={(entity) => {
|
|
399
|
+
console.log("333 EntityEditView onEntityChange:", entity);
|
|
393
400
|
setUsedEntity(entity);
|
|
394
401
|
formProps?.onEntityChange?.(entity);
|
|
395
402
|
}}
|
|
@@ -447,7 +454,12 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
447
454
|
{shouldShowTopBar && <div
|
|
448
455
|
className={cls("h-14 items-center flex overflow-visible overflow-x-scroll w-full no-scrollbar h-14 border-b pl-2 pr-2 pt-1 flex bg-surface-50 dark:bg-surface-900", defaultBorderMixin)}>
|
|
449
456
|
|
|
450
|
-
{barActions
|
|
457
|
+
{barActions?.({
|
|
458
|
+
path: fullIdPath ?? path,
|
|
459
|
+
entityId,
|
|
460
|
+
values: formContext?.values ?? usedEntity?.values ?? {},
|
|
461
|
+
status
|
|
462
|
+
})}
|
|
451
463
|
|
|
452
464
|
<div className={"flex-grow"}/>
|
|
453
465
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo } from "react";
|
|
2
2
|
|
|
3
|
-
import { EntitySidePanelProps } from "../types";
|
|
3
|
+
import { EntityCollection, EntitySidePanelProps } from "../types";
|
|
4
4
|
import { useNavigationController, useSideEntityController } from "../hooks";
|
|
5
5
|
|
|
6
6
|
import { ErrorBoundary } from "../components";
|
|
@@ -8,6 +8,7 @@ import { EntityEditView, OnUpdateParams } from "./EntityEditView";
|
|
|
8
8
|
import { useSideDialogContext } from "./SideDialogs";
|
|
9
9
|
import { CloseIcon, IconButton, OpenInFullIcon } from "@firecms/ui";
|
|
10
10
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
11
|
+
import { saveEntityToMemoryCache } from "../util/entity_cache";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* This is the component in charge of rendering the side dialog used
|
|
@@ -114,11 +115,14 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
114
115
|
{...props}
|
|
115
116
|
fullIdPath={fullIdPath}
|
|
116
117
|
layout={"side_panel"}
|
|
117
|
-
collection={collection}
|
|
118
|
+
collection={collection as EntityCollection}
|
|
118
119
|
parentCollectionIds={parentCollectionIds}
|
|
119
120
|
onValuesModified={onValuesModified}
|
|
120
121
|
onSaved={onUpdate}
|
|
121
|
-
barActions={
|
|
122
|
+
barActions={({
|
|
123
|
+
status,
|
|
124
|
+
values
|
|
125
|
+
}) => <>
|
|
122
126
|
<IconButton
|
|
123
127
|
className="self-center"
|
|
124
128
|
onClick={onClose}>
|
|
@@ -127,6 +131,8 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
127
131
|
{allowFullScreen && <IconButton
|
|
128
132
|
className="self-center"
|
|
129
133
|
onClick={() => {
|
|
134
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
|
|
135
|
+
saveEntityToMemoryCache(key, values);
|
|
130
136
|
if (entityId)
|
|
131
137
|
navigate(location.pathname);
|
|
132
138
|
else
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { ErrorBoundary, getFormFieldKeys } from "../components";
|
|
|
19
19
|
import {
|
|
20
20
|
getDefaultValuesFor,
|
|
21
21
|
getEntityTitlePropertyKey,
|
|
22
|
+
getLocalChangesBackup,
|
|
22
23
|
getValueInPath,
|
|
23
24
|
isHidden,
|
|
24
25
|
isReadOnly,
|
|
@@ -42,11 +43,17 @@ import { flattenKeys, Formex, FormexController, getIn, setIn, useCreateFormex }
|
|
|
42
43
|
import { useAnalyticsController } from "../hooks/useAnalyticsController";
|
|
43
44
|
import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
|
|
44
45
|
import { ValidationError } from "yup";
|
|
45
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
getEntityFromCache,
|
|
48
|
+
removeEntityFromCache,
|
|
49
|
+
removeEntityFromMemoryCache,
|
|
50
|
+
saveEntityToCache
|
|
51
|
+
} from "../util/entity_cache";
|
|
46
52
|
import { CustomIdField } from "./components/CustomIdField";
|
|
47
53
|
import { ErrorFocus } from "./components/ErrorFocus";
|
|
48
54
|
import { CustomFieldValidator, getYupEntitySchema } from "./validation";
|
|
49
55
|
import { EntityFormActions, EntityFormActionsProps } from "./EntityFormActions";
|
|
56
|
+
import { LocalChangesMenu } from "./components/LocalChangesMenu";
|
|
50
57
|
|
|
51
58
|
export type OnUpdateParams = {
|
|
52
59
|
entity: Entity<any>,
|
|
@@ -65,7 +72,7 @@ export type EntityFormProps<M extends Record<string, any>> = {
|
|
|
65
72
|
entity?: Entity<M>;
|
|
66
73
|
databaseId?: string;
|
|
67
74
|
onIdChange?: (id: string) => void;
|
|
68
|
-
onValuesModified?: (modified: boolean) => void;
|
|
75
|
+
onValuesModified?: (modified: boolean, values: M) => void;
|
|
69
76
|
onSaved?: (params: OnUpdateParams) => void;
|
|
70
77
|
initialDirtyValues?: Partial<M>; // dirty cached entity in memory
|
|
71
78
|
onFormContextReady?: (formContext: FormContext) => void;
|
|
@@ -205,6 +212,18 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
205
212
|
|
|
206
213
|
const autoSave = collection.formAutoSave && !collection.customId;
|
|
207
214
|
|
|
215
|
+
const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
|
|
216
|
+
|
|
217
|
+
const localChangesDataRaw = useMemo(() => entityId
|
|
218
|
+
? getEntityFromCache(path + "/" + entityId)
|
|
219
|
+
: getEntityFromCache(path + "#new"), [entityId, path]);
|
|
220
|
+
|
|
221
|
+
const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
|
|
222
|
+
|
|
223
|
+
const localChangesBackup = getLocalChangesBackup(collection);
|
|
224
|
+
const autoApplyLocalChanges = localChangesBackup === "auto_apply";
|
|
225
|
+
const manualApplyLocalChanges = localChangesBackup === "manual_apply";
|
|
226
|
+
|
|
208
227
|
const onSubmit = (values: EntityValues<M>, formexController: FormexController<EntityValues<M>>) => {
|
|
209
228
|
|
|
210
229
|
if (mustSetCustomId && !entityId) {
|
|
@@ -242,9 +261,31 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
242
261
|
});
|
|
243
262
|
};
|
|
244
263
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
264
|
+
const [initialValues, initialDirty] = useMemo(() => {
|
|
265
|
+
const initialValuesWithLocalChanges: Partial<M> = autoApplyLocalChanges && localChangesDataRaw ? mergeDeep(baseInitialValues, localChangesDataRaw as Partial<M>) : baseInitialValues;
|
|
266
|
+
const initialValues = initialDirtyValues ? mergeDeep(initialValuesWithLocalChanges, initialDirtyValues) : initialValuesWithLocalChanges;
|
|
267
|
+
const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
|
|
268
|
+
return [initialValues, initialDirty];
|
|
269
|
+
}, [autoApplyLocalChanges, localChangesDataRaw, baseInitialValues, initialDirtyValues]);
|
|
270
|
+
|
|
271
|
+
const localChangesData = useMemo(() => {
|
|
272
|
+
if (!localChangesDataRaw) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
let filteredChanges = {};
|
|
276
|
+
const flattenedKeys = flattenKeys(localChangesDataRaw);
|
|
277
|
+
flattenedKeys.forEach(key => {
|
|
278
|
+
const localValue = getIn(localChangesDataRaw, key);
|
|
279
|
+
const initialValue = getIn(initialValues, key);
|
|
280
|
+
if (!equal(localValue, initialValue)) {
|
|
281
|
+
filteredChanges = setIn(filteredChanges, key, localValue);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return filteredChanges;
|
|
285
|
+
}, [localChangesDataRaw, initialValues]);
|
|
286
|
+
|
|
287
|
+
const hasLocalChanges = !localChangesCleared && localChangesData && Object.keys(localChangesData).length > 0;
|
|
288
|
+
|
|
248
289
|
const formex: FormexController<M> = formexProp ?? useCreateFormex<M>({
|
|
249
290
|
initialValues: initialValues as M,
|
|
250
291
|
initialDirty,
|
|
@@ -258,7 +299,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
258
299
|
onSubmit,
|
|
259
300
|
onReset: () => {
|
|
260
301
|
clearDirtyCache();
|
|
261
|
-
onValuesModified?.(false);
|
|
302
|
+
onValuesModified?.(false, initialValues as M);
|
|
262
303
|
},
|
|
263
304
|
onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
|
|
264
305
|
const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
|
|
@@ -328,8 +369,10 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
328
369
|
|
|
329
370
|
function clearDirtyCache() {
|
|
330
371
|
if (status === "new" || status === "copy") {
|
|
372
|
+
removeEntityFromMemoryCache(path + "#new");
|
|
331
373
|
removeEntityFromCache(path + "#new");
|
|
332
374
|
} else {
|
|
375
|
+
removeEntityFromMemoryCache(path + "/" + entityId);
|
|
333
376
|
removeEntityFromCache(path + "/" + entityId);
|
|
334
377
|
}
|
|
335
378
|
}
|
|
@@ -337,7 +380,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
337
380
|
const onSaveSuccess = (updatedEntity: Entity<M>) => {
|
|
338
381
|
|
|
339
382
|
clearDirtyCache();
|
|
340
|
-
onValuesModified?.(false);
|
|
383
|
+
onValuesModified?.(false, updatedEntity.values);
|
|
341
384
|
if (!autoSave)
|
|
342
385
|
snackbarController.open({
|
|
343
386
|
type: "success",
|
|
@@ -537,7 +580,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
537
580
|
|
|
538
581
|
useEffect(() => {
|
|
539
582
|
if (!autoSave) {
|
|
540
|
-
onValuesModified?.(modified);
|
|
583
|
+
onValuesModified?.(modified, formex.values);
|
|
541
584
|
}
|
|
542
585
|
}, [formex.dirty]);
|
|
543
586
|
|
|
@@ -750,27 +793,39 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
750
793
|
className={cls("relative flex flex-row max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-6xl w-full h-fit")}>
|
|
751
794
|
|
|
752
795
|
<div className={cls("flex flex-col w-full pt-12 pb-16 px-4 sm:px-8 md:px-10")}>
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
<
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
796
|
+
<div
|
|
797
|
+
className={"flex flex-row gap-4 self-end sticky top-4 z-10"}>
|
|
798
|
+
|
|
799
|
+
{manualApplyLocalChanges && hasLocalChanges &&
|
|
800
|
+
<LocalChangesMenu
|
|
801
|
+
cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
|
|
802
|
+
properties={resolvedCollection.properties}
|
|
803
|
+
localChangesData={localChangesData as Partial<M>}
|
|
804
|
+
formex={formex}
|
|
805
|
+
onClearLocalChanges={() => setLocalChangesCleared(true)}
|
|
806
|
+
/>}
|
|
807
|
+
|
|
808
|
+
{formex.dirty
|
|
809
|
+
? <Tooltip title={"There are local unsaved changes"}>
|
|
810
|
+
<Chip size={"small"} colorScheme={"orangeDarker"}>
|
|
811
|
+
<EditIcon size={"smallest"}/>
|
|
812
|
+
</Chip>
|
|
813
|
+
</Tooltip>
|
|
814
|
+
: <Tooltip title={"The current form is in sync with the database"}>
|
|
815
|
+
<Chip size={"small"}>
|
|
816
|
+
<CheckIcon size={"smallest"}/>
|
|
817
|
+
</Chip>
|
|
818
|
+
</Tooltip>}
|
|
819
|
+
</div>
|
|
767
820
|
|
|
768
821
|
{formView}
|
|
769
822
|
|
|
770
823
|
</div>
|
|
771
824
|
|
|
772
825
|
</div>
|
|
826
|
+
|
|
773
827
|
{dialogActions}
|
|
828
|
+
|
|
774
829
|
</form>
|
|
775
830
|
|
|
776
831
|
</Formex>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button, CancelIcon, CheckIcon,
|
|
4
|
+
defaultBorderMixin,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogActions,
|
|
7
|
+
DialogContent,
|
|
8
|
+
KeyboardArrowDownIcon,
|
|
9
|
+
Menu,
|
|
10
|
+
MenuItem,
|
|
11
|
+
Typography, VisibilityIcon,
|
|
12
|
+
WarningIcon
|
|
13
|
+
} from "@firecms/ui";
|
|
14
|
+
import { flattenKeys, FormexController, getIn } from "@firecms/formex";
|
|
15
|
+
import { useSnackbarController } from "../../hooks";
|
|
16
|
+
import { mergeDeep } from "../../util";
|
|
17
|
+
import { removeEntityFromCache } from "../../util/entity_cache";
|
|
18
|
+
import { getPropertyInPath } from "../../util";
|
|
19
|
+
import { PropertyPreview } from "../../preview";
|
|
20
|
+
import { ResolvedProperties, ResolvedProperty } from "../../types";
|
|
21
|
+
|
|
22
|
+
interface LocalChangesMenuProps<M extends object> {
|
|
23
|
+
cacheKey: string;
|
|
24
|
+
localChangesData: Partial<M>;
|
|
25
|
+
formex: FormexController<M>;
|
|
26
|
+
onClearLocalChanges?: () => void;
|
|
27
|
+
properties: ResolvedProperties<M>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function LocalChangesMenu<M extends object>({
|
|
31
|
+
localChangesData,
|
|
32
|
+
formex,
|
|
33
|
+
onClearLocalChanges,
|
|
34
|
+
cacheKey,
|
|
35
|
+
properties
|
|
36
|
+
}: LocalChangesMenuProps<M>) {
|
|
37
|
+
|
|
38
|
+
const snackbarController = useSnackbarController();
|
|
39
|
+
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
|
40
|
+
const [open, setOpen] = useState(false);
|
|
41
|
+
const handleOpenMenu = () => {
|
|
42
|
+
setOpen(true)
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleCloseMenu = () => {
|
|
46
|
+
setOpen(false)
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handlePreview = () => {
|
|
50
|
+
setPreviewDialogOpen(true);
|
|
51
|
+
handleCloseMenu();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleApply = () => {
|
|
55
|
+
const mergedValues = mergeDeep(formex.values, localChangesData);
|
|
56
|
+
const touched = { ...formex.touched };
|
|
57
|
+
const newTouched: string[] = flattenKeys(localChangesData);
|
|
58
|
+
newTouched.forEach((key) => {
|
|
59
|
+
touched[key] = true;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
formex.setTouched(touched);
|
|
63
|
+
formex.setValues(mergedValues);
|
|
64
|
+
snackbarController.open({
|
|
65
|
+
type: "info",
|
|
66
|
+
message: "Local changes applied to the form"
|
|
67
|
+
});
|
|
68
|
+
handleCloseMenu();
|
|
69
|
+
onClearLocalChanges?.();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleDiscard = () => {
|
|
73
|
+
removeEntityFromCache(cacheKey);
|
|
74
|
+
snackbarController.open({
|
|
75
|
+
type: "info",
|
|
76
|
+
message: "Local changes discarded"
|
|
77
|
+
});
|
|
78
|
+
handleCloseMenu();
|
|
79
|
+
onClearLocalChanges?.();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
|
|
85
|
+
<Menu
|
|
86
|
+
trigger={<Button
|
|
87
|
+
size={"small"}
|
|
88
|
+
className={"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
|
+
onClick={handleOpenMenu}>
|
|
90
|
+
<WarningIcon
|
|
91
|
+
size={"smallest"}
|
|
92
|
+
className={"mr-1 text-yellow-600 dark:text-yellow-400"}/>
|
|
93
|
+
Unsaved Local changes
|
|
94
|
+
<KeyboardArrowDownIcon size={"smallest"}/>
|
|
95
|
+
</Button>}
|
|
96
|
+
open={open}
|
|
97
|
+
onOpenChange={setOpen}
|
|
98
|
+
>
|
|
99
|
+
<div className={"max-w-xs px-4 py-4 text-sm text-gray-700 dark:text-gray-300"}>
|
|
100
|
+
This document was edited locally and has unsaved changes.
|
|
101
|
+
</div>
|
|
102
|
+
<MenuItem dense onClick={handlePreview}><VisibilityIcon size={"small"}/>Preview Changes</MenuItem>
|
|
103
|
+
<MenuItem dense onClick={handleApply}><CheckIcon size={"small"}/>Apply Changes</MenuItem>
|
|
104
|
+
<MenuItem dense onClick={handleDiscard}><CancelIcon size={"small"}/>Discard Local Changes</MenuItem>
|
|
105
|
+
</Menu>
|
|
106
|
+
|
|
107
|
+
<Dialog
|
|
108
|
+
open={previewDialogOpen}
|
|
109
|
+
onOpenChange={setPreviewDialogOpen}
|
|
110
|
+
maxWidth={"4xl"}
|
|
111
|
+
>
|
|
112
|
+
<DialogContent>
|
|
113
|
+
<h3 className={"text-2xl mb-4"}>Preview Local Changes</h3>
|
|
114
|
+
<p className={"mb-4"}>
|
|
115
|
+
These are the local changes that will be applied to the form.
|
|
116
|
+
</p>
|
|
117
|
+
<div
|
|
118
|
+
className={`border rounded-lg divide-y divide-surface-200 divide-surface-opacity-40 dark:divide-surface-700 dark:divide-opacity-40 ${defaultBorderMixin}`}>
|
|
119
|
+
{flattenKeys(localChangesData).map((key) => {
|
|
120
|
+
const value = getIn(localChangesData, key);
|
|
121
|
+
const property = getPropertyInPath(properties, key) as ResolvedProperty;
|
|
122
|
+
if (!property) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return (
|
|
126
|
+
<div key={key}
|
|
127
|
+
className="grid grid-cols-12 gap-x-4 px-4 py-3 items-center">
|
|
128
|
+
<div
|
|
129
|
+
className="col-span-3 text-right">
|
|
130
|
+
<Typography variant="caption"
|
|
131
|
+
className="text-gray-500 dark:text-gray-400 break-words">{property.name || key}</Typography>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="col-span-9">
|
|
134
|
+
<PropertyPreview
|
|
135
|
+
propertyKey={key}
|
|
136
|
+
value={value}
|
|
137
|
+
property={property}
|
|
138
|
+
size={"small"}/>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</div>
|
|
144
|
+
</DialogContent>
|
|
145
|
+
<DialogActions>
|
|
146
|
+
<Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
|
|
147
|
+
<Button
|
|
148
|
+
variant={"filled"}
|
|
149
|
+
onClick={() => {
|
|
150
|
+
handleApply();
|
|
151
|
+
setPreviewDialogOpen(false);
|
|
152
|
+
}}>Apply changes</Button>
|
|
153
|
+
</DialogActions>
|
|
154
|
+
</Dialog>
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
157
|
+
}
|
package/src/types/collections.ts
CHANGED
|
@@ -354,12 +354,16 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
|
|
|
354
354
|
history?: boolean;
|
|
355
355
|
|
|
356
356
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
|
|
362
|
-
|
|
357
|
+
* Should local changes be backed up in local storage, to prevent data loss on
|
|
358
|
+
* accidental navigations.
|
|
359
|
+
* - `manual_apply`: When the user navigates back to an entity with local changes,
|
|
360
|
+
* they will be prompted to restore the changes.
|
|
361
|
+
* - `auto_apply`: When the user navigates back to an entity with local changes,
|
|
362
|
+
* the changes will be automatically applied.
|
|
363
|
+
* - `false`: Local changes will not be backed up.
|
|
364
|
+
* Defaults to `manual_apply`.
|
|
365
|
+
*/
|
|
366
|
+
localChangesBackup?: "manual_apply" | "auto_apply" | false;
|
|
363
367
|
}
|
|
364
368
|
|
|
365
369
|
/**
|
package/src/util/collections.ts
CHANGED
|
@@ -70,3 +70,11 @@ export const applyPermissionsFunctionIfEmpty = (collections: EntityCollection[],
|
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
export function getLocalChangesBackup(collection: EntityCollection) {
|
|
75
|
+
if (!collection.localChangesBackup) {
|
|
76
|
+
return "manual_apply";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return collection.localChangesBackup;
|
|
80
|
+
}
|
|
@@ -4,6 +4,7 @@ export function createFormexStub<T extends object>(values: T): FormexController<
|
|
|
4
4
|
const errorMessage = "You are in a read-only context. You cannot modify the formex controller.";
|
|
5
5
|
|
|
6
6
|
return {
|
|
7
|
+
debugId: "",
|
|
7
8
|
values,
|
|
8
9
|
initialValues: values,
|
|
9
10
|
touched: {} as Record<string, boolean>,
|
|
@@ -19,6 +20,9 @@ export function createFormexStub<T extends object>(values: T): FormexController<
|
|
|
19
20
|
setValues: () => {
|
|
20
21
|
throw new Error(errorMessage);
|
|
21
22
|
},
|
|
23
|
+
setTouched(touched: Record<string, boolean>): void {
|
|
24
|
+
throw new Error(errorMessage);
|
|
25
|
+
},
|
|
22
26
|
setFieldValue: () => {
|
|
23
27
|
throw new Error(errorMessage);
|
|
24
28
|
},
|