@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.
@@ -308,12 +308,16 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
308
308
  */
309
309
  history?: boolean;
310
310
  /**
311
- * If set to true, local changes to entities in this collection will be backed up
312
- * in the browser's local storage. This allows users to recover unsaved changes
313
- * in case of accidental navigation or browser crashes.
314
- * Defaults to `true`.
315
- */
316
- enableLocalChangesBackup?: boolean;
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, useLocalStorage?: boolean): object | undefined;
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.285",
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.285",
57
- "@firecms/formex": "^3.0.0-canary.285",
58
- "@firecms/ui": "^3.0.0-canary.285",
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": "a442a00b64764b353c977cc9e758953995bd9513",
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 hasDraft = hasEntityInCache(fullPath + "/" + entity.id);
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 entity
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 { getEntityFromCache } from "../util/entity_cache";
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
- ? getEntityFromCache(props.path + "/" + entityId, enableLocalChangesBackup)
104
- : getEntityFromCache(props.path + "#new", enableLocalChangesBackup);
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
@@ -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 { removeEntityFromCache, saveEntityToCache } from "../util/entity_cache";
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 baseInitialValues = getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs);
246
- const initialValues = initialDirtyValues ? mergeDeep(baseInitialValues, initialDirtyValues) : baseInitialValues;
247
- const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
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
- {formex.dirty
755
- ? <Tooltip title={"Local unsaved changes"}
756
- className={"self-end sticky top-4 z-10"}>
757
- <Chip size={"small"} colorScheme={"orangeDarker"}>
758
- <EditIcon size={"smallest"}/>
759
- </Chip>
760
- </Tooltip>
761
- : <Tooltip title={"In sync with the database"}
762
- className={"self-end sticky top-4 z-10"}>
763
- <Chip size={"small"}>
764
- <CheckIcon size={"smallest"}/>
765
- </Chip>
766
- </Tooltip>}
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
+ }
@@ -354,12 +354,16 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
354
354
  history?: boolean;
355
355
 
356
356
  /**
357
- * If set to true, local changes to entities in this collection will be backed up
358
- * in the browser's local storage. This allows users to recover unsaved changes
359
- * in case of accidental navigation or browser crashes.
360
- * Defaults to `true`.
361
- */
362
- enableLocalChangesBackup?: boolean;
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
  /**
@@ -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
  },