@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.
@@ -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 { flattenKeys, Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
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 { removeEntityFromCache, saveEntityToCache } from "../util/entity_cache";
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 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;
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
- {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>}
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 <>{value}</>;
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 <>{value}</>;
25
+ return <span className={size === "small" ? "text-sm" : ""}>{value}</span>;
26
26
  }
27
27
  }
@@ -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
  },
@@ -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, useLocalStorage = true): object | undefined {
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 && useLocalStorage) {
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
- entityCache.set(path, entity); // Update the cache
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
+ }
@@ -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
- // If source value is an array, create a shallow copy of the array.
51
- (output as any)[key] = [...sourceValue];
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)) {