@firecms/core 3.0.0-canary.286 → 3.0.0-canary.288
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/PropertyCollectionView.d.ts +23 -0
- package/dist/core/EntityEditView.d.ts +8 -2
- package/dist/form/EntityForm.d.ts +2 -1
- package/dist/form/components/LocalChangesMenu.d.ts +11 -0
- package/dist/index.es.js +848 -71
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +847 -70
- 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 +6 -2
- package/dist/util/objects.d.ts +1 -0
- package/package.json +5 -5
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +4 -3
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +1 -1
- package/src/components/EntityView.tsx +29 -40
- package/src/components/PropertyCollectionView.tsx +329 -0
- package/src/core/EntityEditView.tsx +20 -8
- package/src/core/EntitySidePanel.tsx +9 -3
- package/src/form/EntityForm.tsx +130 -23
- package/src/form/components/LocalChangesMenu.tsx +140 -0
- package/src/preview/property_previews/NumberPropertyPreview.tsx +2 -2
- 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 +53 -22
- package/src/util/objects.ts +40 -2
package/src/form/EntityForm.tsx
CHANGED
|
@@ -19,8 +19,10 @@ import { ErrorBoundary, getFormFieldKeys } from "../components";
|
|
|
19
19
|
import {
|
|
20
20
|
getDefaultValuesFor,
|
|
21
21
|
getEntityTitlePropertyKey,
|
|
22
|
+
getLocalChangesBackup,
|
|
22
23
|
getValueInPath,
|
|
23
24
|
isHidden,
|
|
25
|
+
isObject,
|
|
24
26
|
isReadOnly,
|
|
25
27
|
mergeDeep,
|
|
26
28
|
resolveCollection,
|
|
@@ -38,15 +40,22 @@ import {
|
|
|
38
40
|
useSnackbarController
|
|
39
41
|
} from "../hooks";
|
|
40
42
|
import { Alert, CheckIcon, Chip, cls, EditIcon, NotesIcon, paperMixin, Tooltip, Typography } from "@firecms/ui";
|
|
41
|
-
import {
|
|
43
|
+
import { Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
|
|
42
44
|
import { useAnalyticsController } from "../hooks/useAnalyticsController";
|
|
43
45
|
import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
|
|
44
46
|
import { ValidationError } from "yup";
|
|
45
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
flattenKeys,
|
|
49
|
+
getEntityFromCache,
|
|
50
|
+
removeEntityFromCache,
|
|
51
|
+
removeEntityFromMemoryCache,
|
|
52
|
+
saveEntityToCache
|
|
53
|
+
} from "../util/entity_cache";
|
|
46
54
|
import { CustomIdField } from "./components/CustomIdField";
|
|
47
55
|
import { ErrorFocus } from "./components/ErrorFocus";
|
|
48
56
|
import { CustomFieldValidator, getYupEntitySchema } from "./validation";
|
|
49
57
|
import { EntityFormActions, EntityFormActionsProps } from "./EntityFormActions";
|
|
58
|
+
import { LocalChangesMenu } from "./components/LocalChangesMenu";
|
|
50
59
|
|
|
51
60
|
export type OnUpdateParams = {
|
|
52
61
|
entity: Entity<any>,
|
|
@@ -65,7 +74,7 @@ export type EntityFormProps<M extends Record<string, any>> = {
|
|
|
65
74
|
entity?: Entity<M>;
|
|
66
75
|
databaseId?: string;
|
|
67
76
|
onIdChange?: (id: string) => void;
|
|
68
|
-
onValuesModified?: (modified: boolean) => void;
|
|
77
|
+
onValuesModified?: (modified: boolean, values: M) => void;
|
|
69
78
|
onSaved?: (params: OnUpdateParams) => void;
|
|
70
79
|
initialDirtyValues?: Partial<M>; // dirty cached entity in memory
|
|
71
80
|
onFormContextReady?: (formContext: FormContext) => void;
|
|
@@ -113,6 +122,64 @@ export function extractTouchedValues(values: any, touched: Record<string, boolea
|
|
|
113
122
|
return acc;
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
export function getChanges<T extends object>(source: Partial<T>, comparison: Partial<T>): Partial<T> {
|
|
126
|
+
const changes: Partial<T> = {};
|
|
127
|
+
|
|
128
|
+
if (!source) {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
if (!comparison) {
|
|
132
|
+
return source;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const allKeys = Array.from(new Set([...Object.keys(source), ...Object.keys(comparison)]));
|
|
136
|
+
|
|
137
|
+
for (const key of allKeys) {
|
|
138
|
+
const sourceValue = (source as any)[key];
|
|
139
|
+
const comparisonValue = (comparison as any)[key];
|
|
140
|
+
|
|
141
|
+
if (equal(sourceValue, comparisonValue)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sourceHasKey = source && typeof source === "object" && Object.prototype.hasOwnProperty.call(source, key);
|
|
146
|
+
const comparisonHasKey = comparison && typeof comparison === "object" && Object.prototype.hasOwnProperty.call(comparison, key);
|
|
147
|
+
|
|
148
|
+
if (comparisonHasKey && !sourceHasKey) {
|
|
149
|
+
(changes as any)[key] = undefined;
|
|
150
|
+
} else if (Array.isArray(sourceValue)) {
|
|
151
|
+
const comparisonArray = Array.isArray(comparisonValue) ? comparisonValue : [];
|
|
152
|
+
if (sourceValue.length < comparisonArray.length) {
|
|
153
|
+
(changes as any)[key] = sourceValue;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const changedArray = sourceValue.map((item, index) => {
|
|
157
|
+
const comparisonItem = comparisonArray[index];
|
|
158
|
+
if (equal(item, comparisonItem)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (isObject(item) && item && isObject(comparisonItem) && comparisonItem) {
|
|
162
|
+
const nestedChanges = getChanges(item, comparisonItem);
|
|
163
|
+
return Object.keys(nestedChanges).length > 0 ? nestedChanges : item;
|
|
164
|
+
}
|
|
165
|
+
return item;
|
|
166
|
+
});
|
|
167
|
+
if (changedArray.some(item => item !== null) || sourceValue.length > comparisonArray.length) {
|
|
168
|
+
(changes as any)[key] = changedArray;
|
|
169
|
+
}
|
|
170
|
+
} else if (isObject(sourceValue) && sourceValue && isObject(comparisonValue) && comparisonValue) {
|
|
171
|
+
const nestedChanges = getChanges(sourceValue, comparisonValue);
|
|
172
|
+
if (Object.keys(nestedChanges).length > 0) {
|
|
173
|
+
(changes as any)[key] = nestedChanges;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
(changes as any)[key] = sourceValue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return changes;
|
|
181
|
+
}
|
|
182
|
+
|
|
116
183
|
export function EntityForm<M extends Record<string, any>>({
|
|
117
184
|
path,
|
|
118
185
|
fullIdPath,
|
|
@@ -205,6 +272,18 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
205
272
|
|
|
206
273
|
const autoSave = collection.formAutoSave && !collection.customId;
|
|
207
274
|
|
|
275
|
+
const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
|
|
276
|
+
|
|
277
|
+
const localChangesDataRaw = useMemo(() => entityId
|
|
278
|
+
? getEntityFromCache(path + "/" + entityId)
|
|
279
|
+
: getEntityFromCache(path + "#new"), [entityId, path]);
|
|
280
|
+
|
|
281
|
+
const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
|
|
282
|
+
|
|
283
|
+
const localChangesBackup = getLocalChangesBackup(collection);
|
|
284
|
+
const autoApplyLocalChanges = localChangesBackup === "auto_apply";
|
|
285
|
+
const manualApplyLocalChanges = localChangesBackup === "manual_apply";
|
|
286
|
+
|
|
208
287
|
const onSubmit = (values: EntityValues<M>, formexController: FormexController<EntityValues<M>>) => {
|
|
209
288
|
|
|
210
289
|
if (mustSetCustomId && !entityId) {
|
|
@@ -242,9 +321,22 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
242
321
|
});
|
|
243
322
|
};
|
|
244
323
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
324
|
+
const [initialValues, initialDirty] = useMemo(() => {
|
|
325
|
+
const initialValuesWithLocalChanges: Partial<M> = autoApplyLocalChanges && localChangesDataRaw ? mergeDeep(baseInitialValues, localChangesDataRaw as Partial<M>) : baseInitialValues;
|
|
326
|
+
const initialValues = initialDirtyValues ? mergeDeep(initialValuesWithLocalChanges, initialDirtyValues) : initialValuesWithLocalChanges;
|
|
327
|
+
const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
|
|
328
|
+
return [initialValues, initialDirty];
|
|
329
|
+
}, [autoApplyLocalChanges, localChangesDataRaw, baseInitialValues, initialDirtyValues]);
|
|
330
|
+
|
|
331
|
+
const localChangesData = useMemo(() => {
|
|
332
|
+
if (!localChangesDataRaw) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
return getChanges(localChangesDataRaw, initialValues);
|
|
336
|
+
}, [localChangesDataRaw, initialValues]);
|
|
337
|
+
|
|
338
|
+
const hasLocalChanges = !localChangesCleared && localChangesData && Object.keys(localChangesData).length > 0;
|
|
339
|
+
|
|
248
340
|
const formex: FormexController<M> = formexProp ?? useCreateFormex<M>({
|
|
249
341
|
initialValues: initialValues as M,
|
|
250
342
|
initialDirty,
|
|
@@ -258,7 +350,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
258
350
|
onSubmit,
|
|
259
351
|
onReset: () => {
|
|
260
352
|
clearDirtyCache();
|
|
261
|
-
onValuesModified?.(false);
|
|
353
|
+
onValuesModified?.(false, initialValues as M);
|
|
262
354
|
},
|
|
263
355
|
onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
|
|
264
356
|
const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
|
|
@@ -328,8 +420,10 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
328
420
|
|
|
329
421
|
function clearDirtyCache() {
|
|
330
422
|
if (status === "new" || status === "copy") {
|
|
423
|
+
removeEntityFromMemoryCache(path + "#new");
|
|
331
424
|
removeEntityFromCache(path + "#new");
|
|
332
425
|
} else {
|
|
426
|
+
removeEntityFromMemoryCache(path + "/" + entityId);
|
|
333
427
|
removeEntityFromCache(path + "/" + entityId);
|
|
334
428
|
}
|
|
335
429
|
}
|
|
@@ -337,7 +431,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
337
431
|
const onSaveSuccess = (updatedEntity: Entity<M>) => {
|
|
338
432
|
|
|
339
433
|
clearDirtyCache();
|
|
340
|
-
onValuesModified?.(false);
|
|
434
|
+
onValuesModified?.(false, updatedEntity.values);
|
|
341
435
|
if (!autoSave)
|
|
342
436
|
snackbarController.open({
|
|
343
437
|
type: "success",
|
|
@@ -537,7 +631,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
537
631
|
|
|
538
632
|
useEffect(() => {
|
|
539
633
|
if (!autoSave) {
|
|
540
|
-
onValuesModified?.(modified);
|
|
634
|
+
onValuesModified?.(modified, formex.values);
|
|
541
635
|
}
|
|
542
636
|
}, [formex.dirty]);
|
|
543
637
|
|
|
@@ -750,27 +844,39 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
750
844
|
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
845
|
|
|
752
846
|
<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
|
-
|
|
847
|
+
<div
|
|
848
|
+
className={"flex flex-row gap-4 self-end sticky top-4 z-10"}>
|
|
849
|
+
|
|
850
|
+
{manualApplyLocalChanges && hasLocalChanges &&
|
|
851
|
+
<LocalChangesMenu
|
|
852
|
+
cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
|
|
853
|
+
properties={resolvedCollection.properties}
|
|
854
|
+
localChangesData={localChangesData as Partial<M>}
|
|
855
|
+
formex={formex}
|
|
856
|
+
onClearLocalChanges={() => setLocalChangesCleared(true)}
|
|
857
|
+
/>}
|
|
858
|
+
|
|
859
|
+
{formex.dirty
|
|
860
|
+
? <Tooltip title={"There are local unsaved changes"}>
|
|
861
|
+
<Chip size={"small"} colorScheme={"orangeDarker"}>
|
|
862
|
+
<EditIcon size={"smallest"}/>
|
|
863
|
+
</Chip>
|
|
864
|
+
</Tooltip>
|
|
865
|
+
: <Tooltip title={"The current form is in sync with the database"}>
|
|
866
|
+
<Chip size={"small"}>
|
|
867
|
+
<CheckIcon size={"smallest"}/>
|
|
868
|
+
</Chip>
|
|
869
|
+
</Tooltip>}
|
|
870
|
+
</div>
|
|
767
871
|
|
|
768
872
|
{formView}
|
|
769
873
|
|
|
770
874
|
</div>
|
|
771
875
|
|
|
772
876
|
</div>
|
|
877
|
+
|
|
773
878
|
{dialogActions}
|
|
879
|
+
|
|
774
880
|
</form>
|
|
775
881
|
|
|
776
882
|
</Formex>
|
|
@@ -836,3 +942,4 @@ function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<a
|
|
|
836
942
|
}
|
|
837
943
|
}, [formex.values]);
|
|
838
944
|
}
|
|
945
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
CancelIcon,
|
|
5
|
+
CheckIcon,
|
|
6
|
+
defaultBorderMixin,
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogActions,
|
|
9
|
+
DialogContent,
|
|
10
|
+
KeyboardArrowDownIcon,
|
|
11
|
+
Menu,
|
|
12
|
+
MenuItem, VisibilityIcon,
|
|
13
|
+
WarningIcon
|
|
14
|
+
} from "@firecms/ui";
|
|
15
|
+
import { FormexController } from "@firecms/formex";
|
|
16
|
+
import { useSnackbarController } from "../../hooks";
|
|
17
|
+
import { mergeDeep } from "../../util";
|
|
18
|
+
import { flattenKeys, removeEntityFromCache } from "../../util/entity_cache";
|
|
19
|
+
import { ResolvedProperties } from "../../types";
|
|
20
|
+
import { PropertyCollectionView } from "../../components/PropertyCollectionView";
|
|
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
|
+
|
|
42
|
+
const handleOpenMenu = () => setOpen(true);
|
|
43
|
+
const handleCloseMenu = () => setOpen(false);
|
|
44
|
+
|
|
45
|
+
const handlePreview = () => {
|
|
46
|
+
setPreviewDialogOpen(true);
|
|
47
|
+
handleCloseMenu();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleApply = () => {
|
|
51
|
+
const mergedValues = mergeDeep(formex.values, localChangesData);
|
|
52
|
+
const touched = { ...formex.touched };
|
|
53
|
+
const previewKeys = flattenKeys(localChangesData);
|
|
54
|
+
previewKeys.forEach((key) => {
|
|
55
|
+
touched[key] = true;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
formex.setTouched(touched);
|
|
59
|
+
formex.setValues(mergedValues);
|
|
60
|
+
snackbarController.open({
|
|
61
|
+
type: "info",
|
|
62
|
+
message: "Local changes applied to the form"
|
|
63
|
+
});
|
|
64
|
+
handleCloseMenu();
|
|
65
|
+
onClearLocalChanges?.();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleDiscard = () => {
|
|
69
|
+
removeEntityFromCache(cacheKey);
|
|
70
|
+
snackbarController.open({
|
|
71
|
+
type: "info",
|
|
72
|
+
message: "Local changes discarded"
|
|
73
|
+
});
|
|
74
|
+
handleCloseMenu();
|
|
75
|
+
onClearLocalChanges?.();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
<Menu
|
|
81
|
+
trigger={
|
|
82
|
+
<Button
|
|
83
|
+
size={"small"}
|
|
84
|
+
className={
|
|
85
|
+
"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"
|
|
86
|
+
}
|
|
87
|
+
onClick={handleOpenMenu}
|
|
88
|
+
>
|
|
89
|
+
<WarningIcon size={"smallest"} className={"mr-1 text-yellow-600 dark:text-yellow-400"}/>
|
|
90
|
+
Unsaved Local changes
|
|
91
|
+
<KeyboardArrowDownIcon size={"smallest"}/>
|
|
92
|
+
</Button>
|
|
93
|
+
}
|
|
94
|
+
open={open}
|
|
95
|
+
onOpenChange={setOpen}
|
|
96
|
+
>
|
|
97
|
+
<div className={"max-w-xs px-4 py-4 text-sm text-gray-700 dark:text-gray-300"}>
|
|
98
|
+
This document was edited locally and has unsaved changes. These local changes will be lost if you
|
|
99
|
+
don't apply them.
|
|
100
|
+
</div>
|
|
101
|
+
<MenuItem dense onClick={handlePreview}><VisibilityIcon size={"small"}/>Preview Changes</MenuItem>
|
|
102
|
+
<MenuItem dense onClick={handleApply}><CheckIcon size={"small"}/>Apply Changes</MenuItem>
|
|
103
|
+
<MenuItem dense onClick={handleDiscard}><CancelIcon size={"small"}/>Discard Local Changes</MenuItem>
|
|
104
|
+
</Menu>
|
|
105
|
+
|
|
106
|
+
<Dialog
|
|
107
|
+
open={previewDialogOpen}
|
|
108
|
+
onOpenChange={setPreviewDialogOpen}
|
|
109
|
+
maxWidth={"4xl"}
|
|
110
|
+
>
|
|
111
|
+
<DialogContent>
|
|
112
|
+
<h3 className={"text-2xl mb-4"}>Preview Local Changes</h3>
|
|
113
|
+
<p className={"mb-4"}>
|
|
114
|
+
These are the local changes that will be applied to the form.
|
|
115
|
+
</p>
|
|
116
|
+
<div className={`border rounded-lg ${defaultBorderMixin}`} style={{
|
|
117
|
+
maxHeight: 520,
|
|
118
|
+
overflow: "auto"
|
|
119
|
+
}}>
|
|
120
|
+
<div className="p-4">
|
|
121
|
+
<PropertyCollectionView data={localChangesData} properties={properties as ResolvedProperties}/>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</DialogContent>
|
|
125
|
+
<DialogActions>
|
|
126
|
+
<Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
|
|
127
|
+
<Button
|
|
128
|
+
variant={"filled"}
|
|
129
|
+
onClick={() => {
|
|
130
|
+
handleApply();
|
|
131
|
+
setPreviewDialogOpen(false);
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
Apply changes
|
|
135
|
+
</Button>
|
|
136
|
+
</DialogActions>
|
|
137
|
+
</Dialog>
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -16,12 +16,12 @@ export function NumberPropertyPreview({
|
|
|
16
16
|
const enumKey = value;
|
|
17
17
|
const enumValues = enumToObjectEntries(property.enumValues);
|
|
18
18
|
if (!enumValues)
|
|
19
|
-
return
|
|
19
|
+
return <span className={size === "small" ? "text-sm" : ""}>{value}</span>;
|
|
20
20
|
return <EnumValuesChip
|
|
21
21
|
enumKey={enumKey}
|
|
22
22
|
enumValues={enumValues}
|
|
23
23
|
size={size !== "medium" ? "small" : "medium"}/>;
|
|
24
24
|
} else {
|
|
25
|
-
return
|
|
25
|
+
return <span className={size === "small" ? "text-sm" : ""}>{value}</span>;
|
|
26
26
|
}
|
|
27
27
|
}
|
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
|
},
|
package/src/util/entity_cache.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EntityReference, GeoPoint, Vector } from "../types";
|
|
2
|
+
import { isObject, isPlainObject } from "./objects";
|
|
2
3
|
|
|
3
4
|
// Define a unique prefix for entity keys in localStorage to avoid key collisions
|
|
4
5
|
const LOCAL_STORAGE_PREFIX = "entity_cache::";
|
|
@@ -82,14 +83,15 @@ function customReviver(key: string, value: any): any {
|
|
|
82
83
|
* @param data - The data to cache and persist.
|
|
83
84
|
*/
|
|
84
85
|
export function saveEntityToCache(path: string, data: object): void {
|
|
85
|
-
// Update the in-memory cache
|
|
86
|
-
entityCache.set(path, data);
|
|
87
|
-
|
|
88
86
|
// Persist the data individually in localStorage
|
|
89
87
|
if (isLocalStorageAvailable) {
|
|
90
88
|
try {
|
|
91
89
|
const key = LOCAL_STORAGE_PREFIX + path;
|
|
92
90
|
const entityString = JSON.stringify(data, customReplacer);
|
|
91
|
+
console.log("Saving entity to localStorage:", {
|
|
92
|
+
key,
|
|
93
|
+
entityString
|
|
94
|
+
});
|
|
93
95
|
localStorage.setItem(key, entityString);
|
|
94
96
|
} catch (error) {
|
|
95
97
|
console.error(
|
|
@@ -100,6 +102,22 @@ export function saveEntityToCache(path: string, data: object): void {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
export function removeEntityFromMemoryCache(path: string): void {
|
|
106
|
+
entityCache.delete(path);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function saveEntityToMemoryCache(path: string, data: object): void {
|
|
110
|
+
entityCache.set(path, data);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getEntityFromMemoryCache(path: string): object | undefined {
|
|
114
|
+
return entityCache.get(path);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function hasEntityInCache(path: string): boolean {
|
|
118
|
+
return entityCache.has(path);
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
/**
|
|
104
122
|
* Retrieves an entity from the in-memory cache or `localStorage`.
|
|
105
123
|
* If the entity is not in the cache but exists in `localStorage`, it loads it into the cache.
|
|
@@ -107,21 +125,19 @@ export function saveEntityToCache(path: string, data: object): void {
|
|
|
107
125
|
* @param useLocalStorage
|
|
108
126
|
* @returns The cached entity or `undefined` if not found.
|
|
109
127
|
*/
|
|
110
|
-
export function getEntityFromCache(path: string
|
|
111
|
-
|
|
112
|
-
// Attempt to retrieve the entity from the in-memory cache
|
|
113
|
-
if (entityCache.has(path)) {
|
|
114
|
-
return entityCache.get(path);
|
|
115
|
-
}
|
|
128
|
+
export function getEntityFromCache(path: string): object | undefined {
|
|
116
129
|
|
|
117
130
|
// If not in the cache, attempt to load it from localStorage
|
|
118
|
-
if (isLocalStorageAvailable
|
|
131
|
+
if (isLocalStorageAvailable) {
|
|
119
132
|
try {
|
|
120
133
|
const key = LOCAL_STORAGE_PREFIX + path;
|
|
121
134
|
const entityString = localStorage.getItem(key);
|
|
122
135
|
if (entityString) {
|
|
123
136
|
const entity: object = JSON.parse(entityString, customReviver);
|
|
124
|
-
|
|
137
|
+
console.log("Loaded entity from localStorage:", {
|
|
138
|
+
key,
|
|
139
|
+
entity
|
|
140
|
+
});
|
|
125
141
|
return entity;
|
|
126
142
|
}
|
|
127
143
|
} catch (error) {
|
|
@@ -136,22 +152,11 @@ export function getEntityFromCache(path: string, useLocalStorage = true): object
|
|
|
136
152
|
return undefined;
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
export function hasEntityInCache(path: string): boolean {
|
|
140
|
-
return entityCache.has(path);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
155
|
/**
|
|
144
156
|
* Removes an entity from both the in-memory cache and `localStorage`.
|
|
145
157
|
* @param path - The unique path/key for the entity to remove.
|
|
146
158
|
*/
|
|
147
159
|
export function removeEntityFromCache(path: string): void {
|
|
148
|
-
|
|
149
|
-
console.debug("Removing entity from cache", path);
|
|
150
|
-
|
|
151
|
-
// Remove from the in-memory cache
|
|
152
|
-
entityCache.delete(path);
|
|
153
|
-
|
|
154
|
-
// Remove from localStorage
|
|
155
160
|
if (isLocalStorageAvailable) {
|
|
156
161
|
try {
|
|
157
162
|
const key = LOCAL_STORAGE_PREFIX + path;
|
|
@@ -190,3 +195,29 @@ export function clearEntityCache(): void {
|
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
}
|
|
198
|
+
|
|
199
|
+
export function flattenKeys(obj: any, prefix = "", result: string[] = []): string[] {
|
|
200
|
+
|
|
201
|
+
if (isObject(obj) || Array.isArray(obj)) {
|
|
202
|
+
const plainObject = isPlainObject(obj);
|
|
203
|
+
if (!plainObject && prefix) {
|
|
204
|
+
result.push(prefix);
|
|
205
|
+
} else {
|
|
206
|
+
for (const key in obj) {
|
|
207
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
208
|
+
const newKey = prefix
|
|
209
|
+
? Array.isArray(obj)
|
|
210
|
+
? `${prefix}[${key}]`
|
|
211
|
+
: `${prefix}.${key}`
|
|
212
|
+
: key;
|
|
213
|
+
if (isObject(obj[key]) || Array.isArray(obj[key])) {
|
|
214
|
+
flattenKeys(obj[key], newKey, result);
|
|
215
|
+
} else {
|
|
216
|
+
result.push(newKey);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
package/src/util/objects.ts
CHANGED
|
@@ -12,6 +12,21 @@ export function isObject(item: any) {
|
|
|
12
12
|
return item && typeof item === "object" && !Array.isArray(item);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
|
|
16
|
+
export function isPlainObject(obj:any) {
|
|
17
|
+
// 1. Rule out non-objects, null, and arrays
|
|
18
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 2. Get the object's direct prototype
|
|
23
|
+
const proto = Object.getPrototypeOf(obj);
|
|
24
|
+
|
|
25
|
+
// 3. A plain object's direct prototype is Object.prototype
|
|
26
|
+
return proto === Object.prototype;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
15
30
|
export function mergeDeep<T extends Record<any, any>, U extends Record<any, any>>(
|
|
16
31
|
target: T,
|
|
17
32
|
source: U,
|
|
@@ -47,8 +62,31 @@ export function mergeDeep<T extends Record<any, any>, U extends Record<any, any>
|
|
|
47
62
|
// If source value is a Date, create a new Date instance.
|
|
48
63
|
(output as any)[key] = new Date(sourceValue.getTime());
|
|
49
64
|
} else if (Array.isArray(sourceValue)) {
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
if (Array.isArray(outputValue)) {
|
|
66
|
+
const newArray = [];
|
|
67
|
+
const maxLength = Math.max(outputValue.length, sourceValue.length);
|
|
68
|
+
for (let i = 0; i < maxLength; i++) {
|
|
69
|
+
const sourceItem = sourceValue[i];
|
|
70
|
+
const targetItem = outputValue[i];
|
|
71
|
+
|
|
72
|
+
if (i >= sourceValue.length) { // source is shorter
|
|
73
|
+
newArray[i] = targetItem;
|
|
74
|
+
} else if (i >= outputValue.length) { // target is shorter
|
|
75
|
+
newArray[i] = sourceItem;
|
|
76
|
+
} else if (sourceItem === null) {
|
|
77
|
+
newArray[i] = targetItem;
|
|
78
|
+
} else if (isObject(sourceItem) && isObject(targetItem)) {
|
|
79
|
+
newArray[i] = mergeDeep(targetItem, sourceItem, ignoreUndefined);
|
|
80
|
+
} else {
|
|
81
|
+
newArray[i] = sourceItem;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
(output as any)[key] = newArray;
|
|
85
|
+
} else {
|
|
86
|
+
// If output's value (from target) is not an array,
|
|
87
|
+
// overwrite with a shallow copy of the source array.
|
|
88
|
+
(output as any)[key] = [...sourceValue];
|
|
89
|
+
}
|
|
52
90
|
} else if (isObject(sourceValue)) {
|
|
53
91
|
// If source value is an object:
|
|
54
92
|
if (isObject(outputValue)) {
|