@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
@@ -0,0 +1,144 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ Button,
4
+ CancelIcon,
5
+ CheckIcon,
6
+ defaultBorderMixin,
7
+ Dialog,
8
+ DialogActions,
9
+ DialogContent,
10
+ DialogTitle,
11
+ KeyboardArrowDownIcon,
12
+ Menu,
13
+ MenuItem,
14
+ Typography,
15
+ VisibilityIcon,
16
+ WarningIcon
17
+ } from "@firecms/ui";
18
+ import { FormexController } from "@firecms/formex";
19
+ import { useSnackbarController } from "../../hooks";
20
+ import { mergeDeep } from "../../util";
21
+ import { flattenKeys, removeEntityFromCache } from "../../util/entity_cache";
22
+ import { ResolvedProperties } from "../../types";
23
+ import { PropertyCollectionView } from "../../components/PropertyCollectionView";
24
+
25
+ interface LocalChangesMenuProps<M extends object> {
26
+ cacheKey: string;
27
+ localChangesData: Partial<M>;
28
+ formex: FormexController<M>;
29
+ onClearLocalChanges?: () => void;
30
+ properties: ResolvedProperties<M>;
31
+ }
32
+
33
+ export function LocalChangesMenu<M extends object>({
34
+ localChangesData,
35
+ formex,
36
+ onClearLocalChanges,
37
+ cacheKey,
38
+ properties
39
+ }: LocalChangesMenuProps<M>) {
40
+
41
+ const snackbarController = useSnackbarController();
42
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
43
+ const [open, setOpen] = useState(false);
44
+
45
+ const handleOpenMenu = () => setOpen(true);
46
+ const handleCloseMenu = () => setOpen(false);
47
+
48
+ const handlePreview = () => {
49
+ setPreviewDialogOpen(true);
50
+ handleCloseMenu();
51
+ };
52
+
53
+ const handleApply = () => {
54
+ const mergedValues = mergeDeep(formex.values, localChangesData);
55
+ const touched = { ...formex.touched };
56
+ const previewKeys = flattenKeys(localChangesData);
57
+ previewKeys.forEach((key) => {
58
+ touched[key] = true;
59
+ });
60
+
61
+ formex.setTouched(touched);
62
+ formex.setValues(mergedValues);
63
+ snackbarController.open({
64
+ type: "info",
65
+ message: "Local changes applied to the form"
66
+ });
67
+ handleCloseMenu();
68
+ onClearLocalChanges?.();
69
+ };
70
+
71
+ const handleDiscard = () => {
72
+ removeEntityFromCache(cacheKey);
73
+ snackbarController.open({
74
+ type: "info",
75
+ message: "Local changes discarded"
76
+ });
77
+ handleCloseMenu();
78
+ onClearLocalChanges?.();
79
+ };
80
+
81
+ return (
82
+ <>
83
+ <Menu
84
+ trigger={
85
+ <Button
86
+ size={"small"}
87
+ className={
88
+ "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
+ }
90
+ onClick={handleOpenMenu}
91
+ >
92
+ <WarningIcon size={"smallest"} className={"mr-1 text-yellow-600 dark:text-yellow-400"}/>
93
+ Unsaved Local changes
94
+ <KeyboardArrowDownIcon size={"smallest"}/>
95
+ </Button>
96
+ }
97
+ open={open}
98
+ onOpenChange={setOpen}
99
+ >
100
+ <div className={"max-w-xs px-4 py-4 text-sm text-gray-700 dark:text-gray-300"}>
101
+ This document was edited locally and has unsaved changes. These local changes will be lost if you
102
+ don't apply them.
103
+ </div>
104
+ <MenuItem dense onClick={handlePreview}><VisibilityIcon size={"small"}/>Preview Changes</MenuItem>
105
+ <MenuItem dense onClick={handleApply}><CheckIcon size={"small"}/>Apply Changes</MenuItem>
106
+ <MenuItem dense onClick={handleDiscard}><CancelIcon size={"small"}/>Discard Local Changes</MenuItem>
107
+ </Menu>
108
+
109
+ <Dialog
110
+ open={previewDialogOpen}
111
+ onOpenChange={setPreviewDialogOpen}
112
+ maxWidth={"4xl"}
113
+ >
114
+ <DialogTitle variant={"h6"}>Preview Local Changes</DialogTitle>
115
+ <DialogContent className={"my-4"}>
116
+ <Typography variant={"body2"} className={"mb-4"}>
117
+ These are the local changes that will be applied to the form.
118
+ </Typography>
119
+ <div className={`border rounded-lg ${defaultBorderMixin}`} style={{
120
+ maxHeight: 520,
121
+ overflow: "auto"
122
+ }}>
123
+ <div className="p-4">
124
+ <PropertyCollectionView data={localChangesData}
125
+ properties={properties as ResolvedProperties}/>
126
+ </div>
127
+ </div>
128
+ </DialogContent>
129
+ <DialogActions>
130
+ <Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
131
+ <Button
132
+ variant={"filled"}
133
+ onClick={() => {
134
+ handleApply();
135
+ setPreviewDialogOpen(false);
136
+ }}
137
+ >
138
+ Apply changes
139
+ </Button>
140
+ </DialogActions>
141
+ </Dialog>
142
+ </>
143
+ );
144
+ }
@@ -1,4 +1,8 @@
1
- export * from "./EntityForm";
1
+ export {
2
+ EntityForm,
3
+ yupToFormErrors,
4
+ } from "./EntityForm";
5
+ export type { EntityFormProps } from "./EntityForm";
2
6
 
3
7
  export { SelectFieldBinding } from "./field_bindings/SelectFieldBinding";
4
8
  export { MultiSelectFieldBinding } from "./field_bindings/MultiSelectFieldBinding";
@@ -149,26 +149,10 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
149
149
  const allPluginGroups = plugins?.flatMap(plugin => plugin.homePage?.navigationEntries ? plugin.homePage.navigationEntries.map(e => e.name) : []) ?? [];
150
150
  const pluginGroups = [...new Set(allPluginGroups)];
151
151
 
152
- const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
153
- if (!plugins) {
154
- return;
155
- }
156
- // remove all groups that have no entries
157
- const filteredEntries = entries.filter(entry => entry.entries.length > 0);
158
- if (plugins.some(plugin => plugin.homePage?.onNavigationEntriesUpdate)) {
159
- plugins.forEach(plugin => {
160
- if (plugin.homePage?.onNavigationEntriesUpdate) {
161
- plugin.homePage.onNavigationEntriesUpdate(filteredEntries);
162
- }
163
- });
164
- }
165
-
166
- }, [plugins]);
167
-
168
- const computeTopNavigation = useCallback((collections: EntityCollection[], views: CMSView[], adminViews: CMSView[], viewsOrder?: string[]): NavigationResult => {
152
+ const computeTopNavigation = useCallback((collections: EntityCollection[], views: CMSView[], adminViews: CMSView[], viewsOrder?: string[], navigationGroupMappingsOverride?: NavigationGroupMapping[], onNavigationEntriesUpdateCallback?: (entries: NavigationGroupMapping[]) => void): NavigationResult => {
169
153
 
170
154
  const finalNavigationGroupMappings: NavigationGroupMapping[] = computeNavigationGroups({
171
- navigationGroupMappings: navigationGroupMappings,
155
+ navigationGroupMappings: navigationGroupMappingsOverride ?? navigationGroupMappings,
172
156
  collections,
173
157
  views,
174
158
  plugins: plugins
@@ -209,7 +193,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
209
193
  ...(views ?? []).reduce((acc, view) => {
210
194
  if (view.hideFromNavigation) return acc;
211
195
 
212
- const pathKey = Array.isArray(view.path) ? view.path[0] : view.path;
196
+ const pathKey = view.path;
213
197
  let groupName = getGroup(view); // Initial group
214
198
 
215
199
  if (finalNavigationGroupMappings) {
@@ -237,7 +221,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
237
221
  ...(adminViews ?? []).reduce((acc, view) => {
238
222
  if (view.hideFromNavigation) return acc;
239
223
 
240
- const pathKey = Array.isArray(view.path) ? view.path[0] : view.path;
224
+ const pathKey = view.path;
241
225
  const groupName = NAVIGATION_ADMIN_GROUP_NAME;
242
226
 
243
227
  acc.push({
@@ -280,21 +264,62 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
280
264
  .map(e => e.group)
281
265
  .filter(Boolean) as string[];
282
266
 
267
+ // Preserve order from finalNavigationGroupMappings (persisted order)
268
+ const groupsFromMappings = finalNavigationGroupMappings.map(g => g.name);
269
+
270
+ // Add any additional groups not in mappings
271
+ const additionalGroups = collectedGroupsFromEntries.filter(g => !groupsFromMappings.includes(g));
272
+
283
273
  const allDefinedGroups = [
284
274
  ...(pluginGroups ?? []),
285
- ...collectedGroupsFromEntries
275
+ ...groupsFromMappings,
276
+ ...additionalGroups
286
277
  ];
287
278
 
288
- const uniqueGroups = [...new Set(allDefinedGroups)]
289
- .sort((a, b) => groupOrderValue(a) - groupOrderValue(b));
279
+ // Remove duplicates while preserving order, then separate admin to the end
280
+ const uniqueGroupsArray = [...new Set(allDefinedGroups)];
281
+ const adminGroups = uniqueGroupsArray.filter(g => g === NAVIGATION_ADMIN_GROUP_NAME);
282
+ const nonAdminGroups = uniqueGroupsArray.filter(g => g !== NAVIGATION_ADMIN_GROUP_NAME);
283
+ const uniqueGroups = [...nonAdminGroups, ...adminGroups];
290
284
 
291
285
  return {
292
286
  allowDragAndDrop: plugins?.some(plugin => plugin.homePage?.allowDragAndDrop) ?? false,
293
287
  navigationEntries,
294
288
  groups: uniqueGroups,
295
- onNavigationEntriesUpdate: onNavigationEntriesOrderUpdate,
289
+ onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
296
290
  };
297
- }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, onNavigationEntriesOrderUpdate]);
291
+ }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
292
+
293
+ const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
294
+ if (!plugins) {
295
+ return;
296
+ }
297
+ // remove all groups that have no entries
298
+ const filteredEntries = entries.filter(entry => entry.entries.length > 0);
299
+
300
+ // Immediately update the local topLevelNavigation with new mappings
301
+ if (collectionsRef.current && viewsRef.current) {
302
+ const updatedNav = computeTopNavigation(
303
+ collectionsRef.current,
304
+ viewsRef.current,
305
+ adminViewsRef.current ?? [],
306
+ viewsOrder,
307
+ filteredEntries,
308
+ onNavigationEntriesOrderUpdate
309
+ );
310
+ setTopLevelNavigation(updatedNav);
311
+ }
312
+
313
+ // Then persist to backend
314
+ if (plugins.some(plugin => plugin.homePage?.onNavigationEntriesUpdate)) {
315
+ plugins.forEach(plugin => {
316
+ if (plugin.homePage?.onNavigationEntriesUpdate) {
317
+ plugin.homePage.onNavigationEntriesUpdate(filteredEntries);
318
+ }
319
+ });
320
+ }
321
+
322
+ }, [plugins, computeTopNavigation, viewsOrder]);
298
323
 
299
324
  const refreshNavigation = useCallback(async () => {
300
325
 
@@ -312,7 +337,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
312
337
  ]
313
338
  );
314
339
 
315
- const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder);
340
+ const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
316
341
 
317
342
  let shouldUpdateTopLevelNav = false;
318
343
  if (!areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
@@ -457,8 +482,9 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
457
482
  [fullCollectionPath]);
458
483
 
459
484
  const urlPathToDataPath = useCallback((path: string): string => {
460
- if (path.startsWith(fullCollectionPath))
461
- return path.replace(fullCollectionPath, "");
485
+ const decodedPath = decodeURIComponent(path);
486
+ if (decodedPath.startsWith(fullCollectionPath))
487
+ return decodedPath.replace(fullCollectionPath, "");
462
488
  throw Error("Expected path starting with " + fullCollectionPath);
463
489
  }, [fullCollectionPath]);
464
490
 
@@ -716,6 +742,7 @@ function computeNavigationGroups({
716
742
 
717
743
  let result = navigationGroupMappings;
718
744
 
745
+ // Merge plugin navigation entries
719
746
  result = plugins ? plugins?.reduce((acc, plugin) => {
720
747
  if (plugin.homePage?.navigationEntries) {
721
748
  plugin.homePage.navigationEntries.forEach((entry) => {
@@ -738,8 +765,54 @@ function computeNavigationGroups({
738
765
  return acc;
739
766
  }, [...(result ?? [])] as NavigationGroupMapping[]) : result;
740
767
 
768
+ // Track all entries that are already assigned to groups
769
+ const assignedEntries = new Set<string>();
770
+ if (result) {
771
+ result.forEach(group => {
772
+ group.entries.forEach(entry => assignedEntries.add(entry));
773
+ });
774
+ }
775
+
776
+ // Find collections and views that are NOT in any persisted group
777
+ const unassignedGroupMap: Record<string, string[]> = {};
778
+
779
+ // Check collections
780
+ (collections ?? []).forEach(collection => {
781
+ const entry = collection.id ?? collection.path;
782
+ if (!assignedEntries.has(entry)) {
783
+ const groupName = getGroup(collection);
784
+ if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
785
+ unassignedGroupMap[groupName].push(entry);
786
+ }
787
+ });
788
+
789
+ // Check views
790
+ (views ?? []).forEach(view => {
791
+ const entry = view.path;
792
+ if (!assignedEntries.has(entry)) {
793
+ const groupName = getGroup(view);
794
+ if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
795
+ unassignedGroupMap[groupName].push(entry);
796
+ }
797
+ });
798
+
799
+ // Merge unassigned entries into existing groups or create new groups
800
+ Object.entries(unassignedGroupMap).forEach(([groupName, entries]) => {
801
+ if (result) {
802
+ const existingGroup = result.find(g => g.name === groupName);
803
+ if (existingGroup) {
804
+ existingGroup.entries.push(...entries);
805
+ } else {
806
+ result.push({
807
+ name: groupName,
808
+ entries
809
+ });
810
+ }
811
+ }
812
+ });
813
+
741
814
  if (!result) {
742
- // Convert views and collections to navigation group mappings, grouped by their group name
815
+ // No persisted data at all - create from scratch
743
816
  result = [];
744
817
  const groupMap: Record<string, string[]> = {};
745
818
 
@@ -754,12 +827,12 @@ function computeNavigationGroups({
754
827
  // Add views
755
828
  (views ?? []).forEach(view => {
756
829
  const name = getGroup(view);
757
- const entry = Array.isArray(view.path) ? view.path[0] : view.path;
830
+ const entry = view.path;
758
831
  if (!groupMap[name]) groupMap[name] = [];
759
832
  groupMap[name].push(entry);
760
833
  });
761
834
 
762
- // Convert groupMap to initialGroupMappings array
835
+ // Convert groupMap to result array
763
836
  result = Object.entries(groupMap).map(([name, entries]) => ({
764
837
  name,
765
838
  entries
@@ -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
  }
@@ -352,6 +352,18 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
352
352
  * This prop has no effect if the history plugin is not enabled
353
353
  */
354
354
  history?: boolean;
355
+
356
+ /**
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;
355
367
  }
356
368
 
357
369
  /**
@@ -155,6 +155,10 @@ export interface BaseProperty<T extends CMSType, CustomProps = any> {
155
155
  /**
156
156
  * Should this property be editable. If set to true, the user will be able to modify the property and
157
157
  * save the new config. The saved config will then become the source of truth.
158
+ * Defaults to `true.
159
+ * This props is only useful when you are using the collection editor to modify collection
160
+ * configurations from the CMS itself. You can also use the `editable` prop in the
161
+ * `EntityCollection` interface to disable the edition of all properties in a collection.
158
162
  */
159
163
  editable?: boolean;
160
164
 
@@ -775,8 +779,16 @@ export type StorageConfig = {
775
779
  /**
776
780
  * Use client side image compression and resizing
777
781
  * Will only be applied to these MIME types: image/jpeg, image/png and image/webp
782
+ * @deprecated Use `imageResize` instead
778
783
  */
779
- imageCompression?: ImageCompression;
784
+ imageCompression?: ImageResize;
785
+
786
+ /**
787
+ * Advanced image resizing and cropping configuration.
788
+ * Applied before upload to optimize storage and bandwidth.
789
+ * Only applies to image MIME types: image/jpeg, image/png, image/webp
790
+ */
791
+ imageResize?: ImageResize;
780
792
 
781
793
  /**
782
794
  * Specific metadata set in your uploaded file.
@@ -913,19 +925,36 @@ export type FileType =
913
925
  | "font/*"
914
926
  | string;
915
927
 
916
- export interface ImageCompression {
928
+ export interface ImageResize {
917
929
  /**
918
- * New image max height (ratio is preserved)
930
+ * Maximum width in pixels. Image will be scaled down proportionally if wider.
931
+ */
932
+ maxWidth?: number;
933
+
934
+ /**
935
+ * Maximum height in pixels. Image will be scaled down proportionally if taller.
919
936
  */
920
937
  maxHeight?: number;
921
938
 
922
939
  /**
923
- * New image max width (ratio is preserved)
940
+ * Resize mode determines how the image fits within maxWidth/maxHeight bounds.
941
+ * - `contain`: Scale down to fit within bounds, preserving aspect ratio (default)
942
+ * - `cover`: Scale to fill bounds, preserving aspect ratio (may crop)
924
943
  */
925
- maxWidth?: number;
944
+ mode?: 'contain' | 'cover';
945
+
946
+ /**
947
+ * Output format for the resized image.
948
+ * - `original`: Keep the original format (default)
949
+ * - `jpeg`: Convert to JPEG
950
+ * - `png`: Convert to PNG
951
+ * - `webp`: Convert to WebP
952
+ */
953
+ format?: 'original' | 'jpeg' | 'png' | 'webp';
926
954
 
927
955
  /**
928
- * A number between 0 and 100. Used for the JPEG compression.(if no compress is needed, just set it to 100)
956
+ * Quality for lossy formats (JPEG, WebP). Number between 0 and 100.
957
+ * Higher is better quality but larger file size. Defaults to 80.
929
958
  */
930
959
  quality?: number;
931
960
  }
@@ -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
  },