@firecms/core 3.0.0-rc.2 → 3.0.0-rc.3

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.
Files changed (46) hide show
  1. package/dist/components/HomePage/HomePageDnD.d.ts +2 -1
  2. package/dist/components/PropertyCollectionView.d.ts +23 -0
  3. package/dist/core/EntityEditView.d.ts +10 -4
  4. package/dist/form/EntityForm.d.ts +5 -2
  5. package/dist/form/components/LocalChangesMenu.d.ts +11 -0
  6. package/dist/form/index.d.ts +2 -1
  7. package/dist/index.es.js +1288 -364
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +1287 -363
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/types/collections.d.ts +11 -0
  12. package/dist/types/properties.d.ts +32 -6
  13. package/dist/util/collections.d.ts +1 -0
  14. package/dist/util/entity_cache.d.ts +6 -1
  15. package/dist/util/make_properties_editable.d.ts +1 -2
  16. package/dist/util/objects.d.ts +1 -0
  17. package/dist/util/useStorageUploadController.d.ts +1 -0
  18. package/package.json +6 -6
  19. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +47 -47
  20. package/src/components/EntityCollectionView/EntityCollectionView.tsx +6 -1
  21. package/src/components/EntityView.tsx +29 -40
  22. package/src/components/HomePage/DefaultHomePage.tsx +13 -9
  23. package/src/components/HomePage/HomePageDnD.tsx +140 -38
  24. package/src/components/PropertyCollectionView.tsx +329 -0
  25. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +2 -1
  26. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +0 -1
  27. package/src/core/EntityEditView.tsx +27 -14
  28. package/src/core/EntityEditViewFormActions.tsx +33 -18
  29. package/src/core/EntitySidePanel.tsx +9 -3
  30. package/src/form/EntityForm.tsx +173 -42
  31. package/src/form/EntityFormActions.tsx +30 -15
  32. package/src/form/components/ErrorFocus.tsx +22 -29
  33. package/src/form/components/LocalChangesMenu.tsx +144 -0
  34. package/src/form/index.tsx +5 -1
  35. package/src/hooks/useBuildNavigationController.tsx +104 -31
  36. package/src/preview/property_previews/MapPropertyPreview.tsx +2 -2
  37. package/src/preview/property_previews/NumberPropertyPreview.tsx +2 -2
  38. package/src/types/collections.ts +12 -0
  39. package/src/types/properties.ts +35 -6
  40. package/src/util/collections.ts +8 -0
  41. package/src/util/createFormexStub.tsx +4 -0
  42. package/src/util/entity_cache.ts +71 -52
  43. package/src/util/join_collections.ts +3 -3
  44. package/src/util/make_properties_editable.ts +0 -22
  45. package/src/util/objects.ts +40 -2
  46. package/src/util/useStorageUploadController.tsx +71 -34
@@ -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::";
@@ -18,25 +19,40 @@ function customReplacer(key: string): any {
18
19
  // Handle Date objects
19
20
  // @ts-ignore
20
21
  if (value instanceof Date) {
21
- return { __type: "Date", value: value.toISOString() };
22
+ return {
23
+ __type: "Date",
24
+ value: value.toISOString()
25
+ };
22
26
  }
23
27
 
24
28
  // Handle EntityReference
25
29
  // @ts-ignore
26
30
  if (value instanceof EntityReference) {
27
- return { __type: "EntityReference", id: value.id, path: value.path, databaseId: value.databaseId };
31
+ return {
32
+ __type: "EntityReference",
33
+ id: value.id,
34
+ path: value.path,
35
+ databaseId: value.databaseId
36
+ };
28
37
  }
29
38
 
30
39
  // Handle GeoPoint
31
40
  // @ts-ignore
32
41
  if (value instanceof GeoPoint) {
33
- return { __type: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
42
+ return {
43
+ __type: "GeoPoint",
44
+ latitude: value.latitude,
45
+ longitude: value.longitude
46
+ };
34
47
  }
35
48
 
36
49
  // Handle Vector
37
50
  // @ts-ignore
38
51
  if (value instanceof Vector) {
39
- return { __type: "Vector", value: value.value };
52
+ return {
53
+ __type: "Vector",
54
+ value: value.value
55
+ };
40
56
  }
41
57
 
42
58
  return value;
@@ -61,47 +77,21 @@ function customReviver(key: string, value: any): any {
61
77
  return value;
62
78
  }
63
79
 
64
- // Initialize the in-memory cache by loading entities from `localStorage`
65
- if (isLocalStorageAvailable) {
66
- try {
67
- // Iterate over all keys in localStorage to find those with the specified prefix
68
- for (let i = 0; i < localStorage.length; i++) {
69
- const fullKey = localStorage.key(i);
70
- if (fullKey && fullKey.startsWith(LOCAL_STORAGE_PREFIX)) {
71
- const path = fullKey.substring(LOCAL_STORAGE_PREFIX.length);
72
- const entityString = localStorage.getItem(fullKey);
73
- if (entityString) {
74
- try {
75
- const entity: object = JSON.parse(entityString, customReviver);
76
- entityCache.set(path, entity);
77
- } catch (parseError) {
78
- console.error(
79
- `Failed to parse entity for path "${path}" from localStorage:`,
80
- parseError
81
- );
82
- }
83
- }
84
- }
85
- }
86
- } catch (error) {
87
- console.error("Error accessing localStorage during initialization:", error);
88
- }
89
- }
90
-
91
80
  /**
92
81
  * Saves data to the in-memory cache and persists it individually in `localStorage`.
93
82
  * @param path - The unique path/key for the data.
94
83
  * @param data - The data to cache and persist.
95
84
  */
96
85
  export function saveEntityToCache(path: string, data: object): void {
97
- // Update the in-memory cache
98
- entityCache.set(path, data);
99
-
100
86
  // Persist the data individually in localStorage
101
87
  if (isLocalStorageAvailable) {
102
88
  try {
103
89
  const key = LOCAL_STORAGE_PREFIX + path;
104
90
  const entityString = JSON.stringify(data, customReplacer);
91
+ console.log("Saving entity to localStorage:", {
92
+ key,
93
+ entityString
94
+ });
105
95
  localStorage.setItem(key, entityString);
106
96
  } catch (error) {
107
97
  console.error(
@@ -112,19 +102,31 @@ export function saveEntityToCache(path: string, data: object): void {
112
102
  }
113
103
  }
114
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
+
115
121
  /**
116
122
  * Retrieves an entity from the in-memory cache or `localStorage`.
117
123
  * If the entity is not in the cache but exists in `localStorage`, it loads it into the cache.
118
124
  * @param path - The unique path/key for the entity.
125
+ * @param useLocalStorage
119
126
  * @returns The cached entity or `undefined` if not found.
120
127
  */
121
128
  export function getEntityFromCache(path: string): object | undefined {
122
129
 
123
- // Attempt to retrieve the entity from the in-memory cache
124
- if (entityCache.has(path)) {
125
- return entityCache.get(path);
126
- }
127
-
128
130
  // If not in the cache, attempt to load it from localStorage
129
131
  if (isLocalStorageAvailable) {
130
132
  try {
@@ -132,7 +134,10 @@ export function getEntityFromCache(path: string): object | undefined {
132
134
  const entityString = localStorage.getItem(key);
133
135
  if (entityString) {
134
136
  const entity: object = JSON.parse(entityString, customReviver);
135
- entityCache.set(path, entity); // Update the cache
137
+ console.log("Loaded entity from localStorage:", {
138
+ key,
139
+ entity
140
+ });
136
141
  return entity;
137
142
  }
138
143
  } catch (error) {
@@ -147,23 +152,11 @@ export function getEntityFromCache(path: string): object | undefined {
147
152
  return undefined;
148
153
  }
149
154
 
150
- export function hasEntityInCache(path: string): boolean {
151
- return entityCache.has(path);
152
- }
153
-
154
155
  /**
155
156
  * Removes an entity from both the in-memory cache and `localStorage`.
156
157
  * @param path - The unique path/key for the entity to remove.
157
158
  */
158
159
  export function removeEntityFromCache(path: string): void {
159
-
160
-
161
- console.debug("Removing entity from cache", path);
162
-
163
- // Remove from the in-memory cache
164
- entityCache.delete(path);
165
-
166
- // Remove from localStorage
167
160
  if (isLocalStorageAvailable) {
168
161
  try {
169
162
  const key = LOCAL_STORAGE_PREFIX + path;
@@ -202,3 +195,29 @@ export function clearEntityCache(): void {
202
195
  }
203
196
  }
204
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
+ }
@@ -78,7 +78,6 @@ export function mergeCollection(target: EntityCollection,
78
78
  modifyCollection?: (props: ModifyCollectionProps) => EntityCollection | void
79
79
  ): EntityCollection {
80
80
 
81
-
82
81
  const subcollectionsMerged = joinCollectionLists(
83
82
  target?.subcollections ?? [],
84
83
  source?.subcollections ?? [],
@@ -132,8 +131,9 @@ function mergePropertyOrBuilder(target: PropertyOrBuilder, source: PropertyOrBui
132
131
  return target;
133
132
  } else {
134
133
  const mergedProperty = mergeDeep(target, source);
135
- const targetEditable = Boolean(target.editable);
136
- const sourceEditable = Boolean(source.editable);
134
+ const targetEditable = target.editable === undefined ? true : Boolean(target.editable);
135
+ const sourceEditable = source.editable === undefined ? true : Boolean(source.editable);
136
+
137
137
  if (source.dataType === "map" && source.properties) {
138
138
  const targetProperties = ("properties" in target ? target.properties : {}) as PropertiesOrBuilders;
139
139
  const sourceProperties = ("properties" in source ? source.properties : {}) as PropertiesOrBuilders;
@@ -13,25 +13,3 @@ export function makePropertiesEditable(properties: Properties) {
13
13
  });
14
14
  return properties;
15
15
  }
16
-
17
- export function makePropertiesNonEditable(properties: PropertiesOrBuilders): PropertiesOrBuilders {
18
- return Object.entries(properties).reduce((acc, [key, property]) => {
19
- if (!isPropertyBuilder(property) && property.dataType === "map" && property.properties) {
20
- const updated = {
21
- ...property,
22
- properties: makePropertiesNonEditable(property.properties as PropertiesOrBuilders)
23
- };
24
- acc[key] = updated;
25
- }
26
- if (isPropertyBuilder(property)) {
27
- acc[key] = property;
28
- } else {
29
- acc[key] = {
30
- ...property,
31
- editable: false
32
- };
33
- }
34
- return acc;
35
- }, {} as PropertiesOrBuilders);
36
-
37
- }
@@ -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)) {
@@ -1,10 +1,10 @@
1
- import Resizer from "react-image-file-resizer";
1
+ import Compressor from "compressorjs";
2
2
  import equal from "react-fast-compare";
3
3
 
4
4
  import {
5
5
  ArrayProperty,
6
6
  EntityValues,
7
- ImageCompression,
7
+ ImageResize,
8
8
  Property,
9
9
  PropertyOrBuilder,
10
10
  ResolvedArrayProperty,
@@ -76,7 +76,9 @@ export function useStorageUploadController<M extends object>({
76
76
  const metadata: Record<string, any> | undefined = storage?.metadata;
77
77
  const size = multipleFilesSupported ? "medium" : "large";
78
78
 
79
- const compression: ImageCompression | undefined = storage?.imageCompression;
79
+ // Support both new imageResize and deprecated imageCompression
80
+ const imageResize = storage?.imageResize;
81
+ const legacyCompression = storage?.imageCompression;
80
82
 
81
83
  const internalInitialValue: StorageFieldItem[] =
82
84
  getInternalInitialValue(multipleFilesSupported, value, metadata, size);
@@ -169,6 +171,14 @@ export function useStorageUploadController<M extends object>({
169
171
  }
170
172
  }, [internalValue, multipleFilesSupported, onChange, storage, storageSource]);
171
173
 
174
+ const onFileUploadError = useCallback((entry: StorageFieldItem) => {
175
+ console.debug("onFileUploadError", entry);
176
+
177
+ // Remove the failed entry from internalValue
178
+ const newValue = internalValue.filter(item => item.id !== entry.id);
179
+ setInternalValue(newValue);
180
+ }, [internalValue]);
181
+
172
182
  const onFilesAdded = useCallback(async (acceptedFiles: File[]) => {
173
183
 
174
184
  if (!acceptedFiles.length || disabled)
@@ -193,8 +203,8 @@ export function useStorageUploadController<M extends object>({
193
203
  if (multipleFilesSupported) {
194
204
  newInternalValue = [...internalValue,
195
205
  ...(await Promise.all(acceptedFiles.map(async file => {
196
- if (compression && compressionFormat(file)) {
197
- file = await resizeAndCompressImage(file, compression)
206
+ if ((imageResize || legacyCompression) && isImageFile(file)) {
207
+ file = await resizeImage(file, imageResize, legacyCompression);
198
208
  }
199
209
 
200
210
  return {
@@ -206,9 +216,9 @@ export function useStorageUploadController<M extends object>({
206
216
  } as StorageFieldItem;
207
217
  })))];
208
218
  } else {
209
- let file = acceptedFiles[0]
210
- if (compression && compressionFormat(file)) {
211
- file = await resizeAndCompressImage(file, compression)
219
+ let file = acceptedFiles[0];
220
+ if ((imageResize || legacyCompression) && isImageFile(file)) {
221
+ file = await resizeImage(file, imageResize, legacyCompression);
212
222
  }
213
223
 
214
224
  newInternalValue = [{
@@ -223,7 +233,7 @@ export function useStorageUploadController<M extends object>({
223
233
  // Remove either storage path or file duplicates
224
234
  newInternalValue = removeDuplicates(newInternalValue);
225
235
  setInternalValue(newInternalValue);
226
- }, [disabled, fileNameBuilder, internalValue, metadata, multipleFilesSupported, size]);
236
+ }, [disabled, fileNameBuilder, internalValue, metadata, multipleFilesSupported, size, imageResize, legacyCompression]);
227
237
 
228
238
  return {
229
239
  internalValue,
@@ -232,6 +242,7 @@ export function useStorageUploadController<M extends object>({
232
242
  fileNameBuilder,
233
243
  storagePathBuilder,
234
244
  onFileUploadComplete,
245
+ onFileUploadError,
235
246
  onFilesAdded,
236
247
  multipleFilesSupported
237
248
  }
@@ -276,31 +287,57 @@ function getRandomId() {
276
287
  return Math.floor(Math.random() * Math.floor(Number.MAX_SAFE_INTEGER));
277
288
  }
278
289
 
279
- const supportedTypes: Record<string, string> = {
280
- "image/jpeg": "JPEG",
281
- "image/png": "PNG",
282
- "image/webp": "WEBP"
290
+ /**
291
+ * Check if a file is an image type supported for resizing
292
+ */
293
+ function isImageFile(file: File): boolean {
294
+ return file.type === "image/jpeg" ||
295
+ file.type === "image/png" ||
296
+ file.type === "image/webp";
283
297
  }
284
- const compressionFormat = (file: File) => supportedTypes[file.type] ? supportedTypes[file.type] : null;
285
-
286
- const defaultQuality = 100;
287
- const resizeAndCompressImage = (file: File, compression: ImageCompression) => new Promise<File>((resolve) => {
288
298
 
289
- const inputQuality = compression.quality === undefined ? defaultQuality : compression.quality;
290
- const quality = inputQuality >= 0 ? inputQuality <= 100 ? inputQuality : 100 : 100;
291
-
292
- const format = compressionFormat(file);
293
- if (!format) {
294
- throw Error("resizeAndCompressImage: Unsupported image format");
299
+ /**
300
+ * Resize and compress an image using compressorjs.
301
+ * Supports both the new imageResize API and legacy imageCompression for backward compatibility.
302
+ */
303
+ async function resizeImage(
304
+ file: File,
305
+ imageResize?: StorageConfig["imageResize"],
306
+ legacyCompression?: ImageResize
307
+ ): Promise<File> {
308
+ // Determine configuration (new API takes precedence)
309
+ const maxWidth = imageResize?.maxWidth ?? legacyCompression?.maxWidth;
310
+ const maxHeight = imageResize?.maxHeight ?? legacyCompression?.maxHeight;
311
+ const quality = (imageResize?.quality ?? legacyCompression?.quality ?? 80) / 100;
312
+ const mode = imageResize?.mode ?? "contain";
313
+
314
+ // Determine output format
315
+ let mimeType = file.type;
316
+ if (imageResize?.format && imageResize.format !== "original") {
317
+ mimeType = `image/${imageResize.format}`;
295
318
  }
296
- Resizer.imageFileResizer(
297
- file,
298
- compression.maxWidth || Number.MAX_VALUE,
299
- compression.maxHeight || Number.MAX_VALUE,
300
- format,
301
- quality,
302
- 0,
303
- (file: string | Blob | File | ProgressEvent<FileReader>) => resolve(file as File),
304
- "file"
305
- )
306
- });
319
+
320
+ return new Promise<File>((resolve, reject) => {
321
+ new Compressor(file, {
322
+ quality,
323
+ maxWidth,
324
+ maxHeight,
325
+ mimeType,
326
+ // Use cover mode if specified (crops to fit)
327
+ // Otherwise use contain mode (scales to fit)
328
+ ...(mode === "cover" || mode === undefined ? {
329
+ width: maxWidth,
330
+ height: maxHeight,
331
+ resize: "cover" as const
332
+ } : {}),
333
+ success: (result) => {
334
+ const compressedFile = new File([result], file.name, {
335
+ type: result.type,
336
+ lastModified: Date.now(),
337
+ });
338
+ resolve(compressedFile);
339
+ },
340
+ error: reject,
341
+ });
342
+ });
343
+ }