@firecms/core 3.0.0-canary.287 → 3.0.0-canary.289

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.
@@ -22,6 +22,7 @@ import {
22
22
  getLocalChangesBackup,
23
23
  getValueInPath,
24
24
  isHidden,
25
+ isObject,
25
26
  isReadOnly,
26
27
  mergeDeep,
27
28
  resolveCollection,
@@ -39,11 +40,12 @@ import {
39
40
  useSnackbarController
40
41
  } from "../hooks";
41
42
  import { Alert, CheckIcon, Chip, cls, EditIcon, NotesIcon, paperMixin, Tooltip, Typography } from "@firecms/ui";
42
- import { flattenKeys, Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
43
+ import { Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
43
44
  import { useAnalyticsController } from "../hooks/useAnalyticsController";
44
45
  import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
45
46
  import { ValidationError } from "yup";
46
47
  import {
48
+ flattenKeys,
47
49
  getEntityFromCache,
48
50
  removeEntityFromCache,
49
51
  removeEntityFromMemoryCache,
@@ -120,6 +122,64 @@ export function extractTouchedValues(values: any, touched: Record<string, boolea
120
122
  return acc;
121
123
  }
122
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
+
123
183
  export function EntityForm<M extends Record<string, any>>({
124
184
  path,
125
185
  fullIdPath,
@@ -272,16 +332,7 @@ export function EntityForm<M extends Record<string, any>>({
272
332
  if (!localChangesDataRaw) {
273
333
  return undefined;
274
334
  }
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;
335
+ return getChanges(localChangesDataRaw, initialValues);
285
336
  }, [localChangesDataRaw, initialValues]);
286
337
 
287
338
  const hasLocalChanges = !localChangesCleared && localChangesData && Object.keys(localChangesData).length > 0;
@@ -891,3 +942,4 @@ function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<a
891
942
  }
892
943
  }, [formex.values]);
893
944
  }
945
+
@@ -1,23 +1,23 @@
1
1
  import React, { useState } from "react";
2
2
  import {
3
- Button, CancelIcon, CheckIcon,
3
+ Button,
4
+ CancelIcon,
5
+ CheckIcon,
4
6
  defaultBorderMixin,
5
7
  Dialog,
6
8
  DialogActions,
7
9
  DialogContent,
8
10
  KeyboardArrowDownIcon,
9
11
  Menu,
10
- MenuItem,
11
- Typography, VisibilityIcon,
12
+ MenuItem, VisibilityIcon,
12
13
  WarningIcon
13
14
  } from "@firecms/ui";
14
- import { flattenKeys, FormexController, getIn } from "@firecms/formex";
15
+ import { FormexController } from "@firecms/formex";
15
16
  import { useSnackbarController } from "../../hooks";
16
17
  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";
18
+ import { flattenKeys, removeEntityFromCache } from "../../util/entity_cache";
19
+ import { ResolvedProperties } from "../../types";
20
+ import { PropertyCollectionView } from "../../components/PropertyCollectionView";
21
21
 
22
22
  interface LocalChangesMenuProps<M extends object> {
23
23
  cacheKey: string;
@@ -38,13 +38,9 @@ export function LocalChangesMenu<M extends object>({
38
38
  const snackbarController = useSnackbarController();
39
39
  const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
40
40
  const [open, setOpen] = useState(false);
41
- const handleOpenMenu = () => {
42
- setOpen(true)
43
- };
44
41
 
45
- const handleCloseMenu = () => {
46
- setOpen(false)
47
- };
42
+ const handleOpenMenu = () => setOpen(true);
43
+ const handleCloseMenu = () => setOpen(false);
48
44
 
49
45
  const handlePreview = () => {
50
46
  setPreviewDialogOpen(true);
@@ -54,8 +50,8 @@ export function LocalChangesMenu<M extends object>({
54
50
  const handleApply = () => {
55
51
  const mergedValues = mergeDeep(formex.values, localChangesData);
56
52
  const touched = { ...formex.touched };
57
- const newTouched: string[] = flattenKeys(localChangesData);
58
- newTouched.forEach((key) => {
53
+ const previewKeys = flattenKeys(localChangesData);
54
+ previewKeys.forEach((key) => {
59
55
  touched[key] = true;
60
56
  });
61
57
 
@@ -81,23 +77,26 @@ export function LocalChangesMenu<M extends object>({
81
77
 
82
78
  return (
83
79
  <>
84
-
85
80
  <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>}
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
+ }
96
94
  open={open}
97
95
  onOpenChange={setOpen}
98
96
  >
99
97
  <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.
98
+ This document was edited locally and has unsaved changes. These local changes will be lost if you
99
+ don't apply them.
101
100
  </div>
102
101
  <MenuItem dense onClick={handlePreview}><VisibilityIcon size={"small"}/>Preview Changes</MenuItem>
103
102
  <MenuItem dense onClick={handleApply}><CheckIcon size={"small"}/>Apply Changes</MenuItem>
@@ -114,32 +113,13 @@ export function LocalChangesMenu<M extends object>({
114
113
  <p className={"mb-4"}>
115
114
  These are the local changes that will be applied to the form.
116
115
  </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
- })}
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>
143
123
  </div>
144
124
  </DialogContent>
145
125
  <DialogActions>
@@ -149,7 +129,10 @@ export function LocalChangesMenu<M extends object>({
149
129
  onClick={() => {
150
130
  handleApply();
151
131
  setPreviewDialogOpen(false);
152
- }}>Apply changes</Button>
132
+ }}
133
+ >
134
+ Apply changes
135
+ </Button>
153
136
  </DialogActions>
154
137
  </Dialog>
155
138
  </>
@@ -68,7 +68,7 @@ export function MapPropertyPreview<T extends Record<string, any> = Record<string
68
68
  <div
69
69
  className="min-w-[140px] w-[25%] py-1">
70
70
  <Typography variant={"caption"}
71
- className={"font-mono break-words"}
71
+ className={"break-words font-semibold"}
72
72
  color={"secondary"}>
73
73
  {childProperty.name}
74
74
  </Typography>
@@ -121,7 +121,7 @@ export function KeyValuePreview({ value }: { value: any }) {
121
121
  key={`table-cell-title-${key}-${key}`}
122
122
  className="min-w-[140px] w-[25%] py-1">
123
123
  <Typography variant={"caption"}
124
- className={"font-mono break-words"}
124
+ className={"font-semibold break-words"}
125
125
  color={"secondary"}>
126
126
  {key}
127
127
  </Typography>
@@ -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
  }
@@ -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::";
@@ -87,6 +88,10 @@ export function saveEntityToCache(path: string, data: object): void {
87
88
  try {
88
89
  const key = LOCAL_STORAGE_PREFIX + path;
89
90
  const entityString = JSON.stringify(data, customReplacer);
91
+ console.log("Saving entity to localStorage:", {
92
+ key,
93
+ entityString
94
+ });
90
95
  localStorage.setItem(key, entityString);
91
96
  } catch (error) {
92
97
  console.error(
@@ -129,6 +134,10 @@ export function getEntityFromCache(path: string): object | undefined {
129
134
  const entityString = localStorage.getItem(key);
130
135
  if (entityString) {
131
136
  const entity: object = JSON.parse(entityString, customReviver);
137
+ console.log("Loaded entity from localStorage:", {
138
+ key,
139
+ entity
140
+ });
132
141
  return entity;
133
142
  }
134
143
  } catch (error) {
@@ -186,3 +195,29 @@ export function clearEntityCache(): void {
186
195
  }
187
196
  }
188
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)) {