@rebasepro/admin 0.6.1 → 0.8.0

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 (187) hide show
  1. package/dist/{CollectionEditorDialog-HNXGxXdO.js → CollectionEditorDialog-BO_aQ-Z4.js} +236 -155
  2. package/dist/CollectionEditorDialog-BO_aQ-Z4.js.map +1 -0
  3. package/dist/{PropertyEditView-C2wp8kQ9.js → PropertyEditView-DKzOpMjR.js} +190 -50
  4. package/dist/PropertyEditView-DKzOpMjR.js.map +1 -0
  5. package/dist/{CollectionsStudioView-B1mN33GB.js → RouterCollectionsStudioView-BQTIExjA.js} +61 -39
  6. package/dist/RouterCollectionsStudioView-BQTIExjA.js.map +1 -0
  7. package/dist/collection_editor/_cms_internals.d.ts +1 -1
  8. package/dist/collection_editor/api/generateCollectionApi.d.ts +3 -2
  9. package/dist/collection_editor/extensibility_types.d.ts +135 -0
  10. package/dist/collection_editor/index.d.ts +6 -0
  11. package/dist/collection_editor/serializable_types.d.ts +370 -0
  12. package/dist/collection_editor/serializable_utils.d.ts +50 -0
  13. package/dist/collection_editor/ui/collection_editor/CollectionEditorDialog.d.ts +3 -3
  14. package/dist/collection_editor/ui/collection_editor/CollectionPropertiesEditorForm.d.ts +12 -1
  15. package/dist/collection_editor/ui/collection_editor/CollectionStudioView.d.ts +13 -1
  16. package/dist/collection_editor/ui/collection_editor/CollectionsStudioView.d.ts +24 -2
  17. package/dist/collection_editor/ui/collection_editor/DisplaySettingsForm.d.ts +2 -1
  18. package/dist/collection_editor/ui/collection_editor/GeneralSettingsForm.d.ts +4 -1
  19. package/dist/collection_editor/ui/collection_editor/PropertyEditView.d.ts +12 -1
  20. package/dist/collection_editor/ui/collection_editor/RouterCollectionStudioView.d.ts +15 -0
  21. package/dist/collection_editor/ui/collection_editor/RouterCollectionsStudioView.d.ts +14 -0
  22. package/dist/collection_editor/useJsonCollectionsConfigController.d.ts +37 -0
  23. package/dist/collection_editor/useSafeSnackbarController.d.ts +2 -0
  24. package/dist/collection_editor_ui.d.ts +4 -0
  25. package/dist/collection_editor_ui.js +33 -4
  26. package/dist/collection_editor_ui.js.map +1 -0
  27. package/dist/components/EntityCollectionTable/EntityCollectionTable.d.ts +1 -1
  28. package/dist/components/EntityCollectionTable/index.d.ts +1 -1
  29. package/dist/components/EntityCollectionView/EntityBoardCard.d.ts +3 -3
  30. package/dist/components/EntityCollectionView/hooks/useKanbanDragAndDrop.d.ts +3 -3
  31. package/dist/components/EntityEditView.d.ts +3 -2
  32. package/dist/components/RebaseCMS.d.ts +1 -1
  33. package/dist/components/SelectableTable/SelectionStore.d.ts +12 -21
  34. package/dist/components/field_configs.d.ts +1 -1
  35. package/dist/editor/editor.d.ts +0 -6
  36. package/dist/editor.js +1 -3
  37. package/dist/editor.js.map +1 -1
  38. package/dist/form/EntityForm.d.ts +10 -9
  39. package/dist/form/EntityFormBinding.d.ts +27 -0
  40. package/dist/form/components/StorageUploadProgress.d.ts +6 -1
  41. package/dist/form/form_utils.d.ts +8 -0
  42. package/dist/form/index.d.ts +5 -2
  43. package/dist/{history-BO6VT85a.js → history-B3cqBzEV.js} +2 -2
  44. package/dist/{history-BO6VT85a.js.map → history-B3cqBzEV.js.map} +1 -1
  45. package/dist/hooks/navigation/contexts/CollectionRegistryContext.d.ts +1 -1
  46. package/dist/hooks/navigation/useAsyncResolver.d.ts +26 -0
  47. package/dist/hooks/navigation/useBuildCollectionRegistryController.d.ts +2 -0
  48. package/dist/hooks/navigation/useTopLevelNavigation.d.ts +1 -1
  49. package/dist/hooks/navigation/utils.d.ts +1 -1
  50. package/dist/index.d.ts +3 -1
  51. package/dist/index.js +2 -2
  52. package/dist/preview/components/StorageThumbnail.d.ts +3 -1
  53. package/dist/routes/CustomViewRoute.d.ts +2 -1
  54. package/dist/{src-BYniefVu.js → src-cxV1ODRG.js} +1731 -1710
  55. package/dist/src-cxV1ODRG.js.map +1 -0
  56. package/dist/types/components/EntityFormActionsProps.d.ts +1 -1
  57. package/dist/types/components/EntityFormProps.d.ts +59 -0
  58. package/dist/types/components/PropertyPreviewProps.d.ts +2 -1
  59. package/dist/types/fields.d.ts +10 -7
  60. package/dist/util/dataControllerAdapter.d.ts +30 -0
  61. package/dist/util/navigation_utils.d.ts +1 -1
  62. package/dist/util/propertyConfigMapper.d.ts +17 -0
  63. package/package.json +8 -8
  64. package/src/collection_editor/_cms_internals.ts +1 -1
  65. package/src/collection_editor/api/generateCollectionApi.ts +3 -2
  66. package/src/collection_editor/extensibility_types.ts +168 -0
  67. package/src/collection_editor/index.ts +46 -0
  68. package/src/collection_editor/pgColumnToProperty.ts +2 -2
  69. package/src/collection_editor/serializable_types.ts +474 -0
  70. package/src/collection_editor/serializable_utils.ts +576 -0
  71. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +6 -6
  72. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +170 -89
  73. package/src/collection_editor/ui/collection_editor/CollectionPropertiesEditorForm.tsx +95 -78
  74. package/src/collection_editor/ui/collection_editor/CollectionStudioView.tsx +23 -20
  75. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +79 -27
  76. package/src/collection_editor/ui/collection_editor/DisplaySettingsForm.tsx +9 -7
  77. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +22 -5
  78. package/src/collection_editor/ui/collection_editor/GetCodeDialog.tsx +3 -3
  79. package/src/collection_editor/ui/collection_editor/PropertyEditView.tsx +154 -18
  80. package/src/collection_editor/ui/collection_editor/PropertyFieldPreview.tsx +6 -6
  81. package/src/collection_editor/ui/collection_editor/PropertyTree.tsx +1 -0
  82. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +44 -0
  83. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +41 -0
  84. package/src/collection_editor/ui/collection_editor/properties/EnumPropertyField.tsx +4 -3
  85. package/src/collection_editor/ui/collection_editor/properties/StoragePropertyField.tsx +35 -1
  86. package/src/collection_editor/ui/collection_editor/templates/blog_template.ts +1 -1
  87. package/src/collection_editor/ui/collection_editor/utils/supported_fields.tsx +0 -1
  88. package/src/collection_editor/ui/collection_editor/utils/update_property_for_widget.ts +7 -29
  89. package/src/collection_editor/useJsonCollectionsConfigController.tsx +286 -0
  90. package/src/collection_editor/useSafeSnackbarController.ts +9 -0
  91. package/src/collection_editor_ui.ts +6 -0
  92. package/src/components/DefaultDrawer.tsx +14 -4
  93. package/src/components/DrawerNavigationGroup.tsx +2 -2
  94. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +11 -9
  95. package/src/components/EntityCollectionTable/fields/TableMultipleRelationField.tsx +2 -1
  96. package/src/components/EntityCollectionTable/fields/TableRelationField.tsx +2 -1
  97. package/src/components/EntityCollectionTable/fields/TableStorageUpload.tsx +23 -20
  98. package/src/components/EntityCollectionTable/index.tsx +1 -1
  99. package/src/components/EntityCollectionTable/table_bindings.tsx +24 -44
  100. package/src/components/EntityCollectionView/EntityBoardCard.tsx +6 -6
  101. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +13 -12
  102. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +40 -190
  103. package/src/components/EntityCollectionView/EntityCollectionListView.tsx +66 -184
  104. package/src/components/EntityCollectionView/EntityCollectionView.tsx +8 -25
  105. package/src/components/EntityCollectionView/hooks/useCollectionInlineEditor.ts +1 -1
  106. package/src/components/EntityCollectionView/hooks/useKanbanDragAndDrop.ts +6 -6
  107. package/src/components/EntityCollectionView/useBoardDataController.tsx +12 -40
  108. package/src/components/EntityCollectionView/useEntityPreviewSlots.ts +3 -3
  109. package/src/components/EntityDetailView.tsx +2 -2
  110. package/src/components/EntityEditView.tsx +7 -6
  111. package/src/components/EntityEditViewFormActions.tsx +35 -29
  112. package/src/components/HomePage/ContentHomePage.tsx +14 -5
  113. package/src/components/RebaseCMS.tsx +3 -3
  114. package/src/components/RebaseNavigation.tsx +74 -9
  115. package/src/components/RelationSelector.tsx +2 -2
  116. package/src/components/SelectableTable/SelectableTable.tsx +13 -5
  117. package/src/components/SelectableTable/SelectionStore.ts +18 -48
  118. package/src/components/SideDialogs.tsx +0 -7
  119. package/src/components/common/default_entity_actions.tsx +22 -11
  120. package/src/components/field_configs.tsx +0 -14
  121. package/src/contexts/BreacrumbsContext.tsx +15 -6
  122. package/src/editor/editor.tsx +4 -7
  123. package/src/editor/selectors/ai-selector.tsx +0 -1
  124. package/src/form/EntityForm.tsx +191 -428
  125. package/src/form/EntityFormActions.tsx +35 -49
  126. package/src/form/EntityFormBinding.tsx +323 -0
  127. package/src/form/components/FormLayout.tsx +1 -1
  128. package/src/form/components/StorageUploadProgress.tsx +9 -2
  129. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +12 -1
  130. package/src/form/field_bindings/MultipleRelationFieldBinding.tsx +4 -4
  131. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -2
  132. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -7
  133. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +19 -15
  134. package/src/form/form_utils.ts +163 -0
  135. package/src/form/index.tsx +14 -6
  136. package/src/hooks/navigation/contexts/CollectionRegistryContext.tsx +2 -2
  137. package/src/hooks/navigation/contexts/NavigationStateContext.tsx +2 -2
  138. package/src/hooks/navigation/contexts/UrlContext.tsx +1 -2
  139. package/src/hooks/navigation/useAsyncResolver.ts +100 -0
  140. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +15 -8
  141. package/src/hooks/navigation/useBuildNavigationStateController.tsx +12 -8
  142. package/src/hooks/navigation/useNavigationResolution.ts +33 -0
  143. package/src/hooks/navigation/useResolvedCollections.ts +29 -71
  144. package/src/hooks/navigation/useResolvedViews.tsx +40 -70
  145. package/src/hooks/navigation/useTopLevelNavigation.ts +20 -16
  146. package/src/hooks/navigation/utils.ts +7 -4
  147. package/src/index.ts +6 -1
  148. package/src/preview/PropertyPreview.tsx +1 -16
  149. package/src/preview/components/StorageThumbnail.tsx +24 -7
  150. package/src/routes/CustomViewRoute.tsx +14 -6
  151. package/src/types/components/EntityFormActionsProps.tsx +1 -1
  152. package/src/types/components/EntityFormProps.tsx +68 -0
  153. package/src/types/components/PropertyPreviewProps.tsx +1 -1
  154. package/src/types/fields.tsx +9 -8
  155. package/src/util/dataControllerAdapter.ts +93 -0
  156. package/src/util/navigation_utils.ts +1 -1
  157. package/src/util/previews.ts +20 -0
  158. package/src/util/propertyConfigMapper.ts +134 -0
  159. package/src/util/property_utils.tsx +1 -2
  160. package/dist/CollectionEditorDialog-HNXGxXdO.js.map +0 -1
  161. package/dist/CollectionsStudioView-B1mN33GB.js.map +0 -1
  162. package/dist/PropertyEditView-C2wp8kQ9.js.map +0 -1
  163. package/dist/components/EntityCollectionTable/fields/VirtualTableDateField.d.ts +0 -12
  164. package/dist/components/EntityCollectionTable/fields/VirtualTableInput.d.ts +0 -10
  165. package/dist/components/EntityCollectionTable/fields/VirtualTableNumberInput.d.ts +0 -9
  166. package/dist/components/EntityCollectionTable/fields/VirtualTableSwitch.d.ts +0 -8
  167. package/dist/components/EntityCollectionView/Board.d.ts +0 -3
  168. package/dist/components/EntityCollectionView/BoardColumn.d.ts +0 -42
  169. package/dist/components/EntityCollectionView/BoardColumnTitle.d.ts +0 -9
  170. package/dist/components/EntityCollectionView/BoardSortableList.d.ts +0 -14
  171. package/dist/components/EntityCollectionView/board_types.d.ts +0 -105
  172. package/dist/form/field_bindings/ReferenceAsStringFieldBinding.d.ts +0 -11
  173. package/dist/hooks/navigation/useNavigationRegistry.d.ts +0 -12
  174. package/dist/hooks/navigation/useNavigationURLs.d.ts +0 -11
  175. package/dist/src-BYniefVu.js.map +0 -1
  176. package/src/components/EntityCollectionTable/fields/VirtualTableDateField.tsx +0 -39
  177. package/src/components/EntityCollectionTable/fields/VirtualTableInput.tsx +0 -90
  178. package/src/components/EntityCollectionTable/fields/VirtualTableNumberInput.tsx +0 -83
  179. package/src/components/EntityCollectionTable/fields/VirtualTableSwitch.tsx +0 -32
  180. package/src/components/EntityCollectionView/Board.tsx +0 -475
  181. package/src/components/EntityCollectionView/BoardColumn.tsx +0 -165
  182. package/src/components/EntityCollectionView/BoardColumnTitle.tsx +0 -46
  183. package/src/components/EntityCollectionView/BoardSortableList.tsx +0 -177
  184. package/src/components/EntityCollectionView/board_types.ts +0 -113
  185. package/src/form/field_bindings/ReferenceAsStringFieldBinding.tsx +0 -141
  186. package/src/hooks/navigation/useNavigationRegistry.ts +0 -164
  187. package/src/hooks/navigation/useNavigationURLs.ts +0 -56
@@ -1,30 +1,22 @@
1
- import type { EntityCollection, EntityCustomViewParams } from "@rebasepro/types";
1
+ import type { EntityCollection, EntityCustomViewParams, AdditionalFieldDelegateProps } from "@rebasepro/types";
2
2
  import type { FormContext, PropertyFieldBindingProps } from "../types/fields";
3
- import type { PluginFormActionProps, PropertyConfig } from "@rebasepro/types";
3
+ import type { PropertyConfig } from "@rebasepro/types";
4
4
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
- import { AuthController, AnalyticsEvent, Entity, EntityStatus, EntityValues } from "@rebasepro/types";
5
+ import { Entity, EntityStatus, EntityValues } from "@rebasepro/types";
6
6
  import type { EntityFormProps, OnUpdateParams } from "../types/components/EntityFormProps";
7
7
  import { deepEqual as equal } from "fast-equals";
8
8
 
9
9
  import { ErrorBoundary } from "@rebasepro/ui";
10
- import { AlignLeftIcon, CheckIcon, PencilIcon, useDebouncedCallback } from "@rebasepro/ui";
11
- import { getDefaultValuesFor, getLocalChangesBackup, isHidden, isReadOnly } from "@rebasepro/common";
10
+ import { AlignLeftIcon, CheckIcon, LoaderIcon, PencilIcon, useDebouncedCallback } from "@rebasepro/ui";
11
+ import { getDefaultValuesFor, isHidden, isReadOnly } from "@rebasepro/common";
12
12
 
13
- import { saveEntityWithCallbacks, useAuthController, useCustomizationController, useData, useSnackbarController, useTranslation, useSlot } from "@rebasepro/core";
13
+ import { useCustomizationController } from "@rebasepro/core";
14
14
  import { getFormFieldKeys } from "@rebasepro/core";
15
- import { Alert, Chip, cls, iconSize, paperMixin, Tooltip, Typography } from "@rebasepro/ui";
16
- import { Formex, FormexController, getIn, setIn, useCreateFormex } from "@rebasepro/formex";
17
- import { useAnalyticsController } from "@rebasepro/core";
15
+ import { Alert, Button, Chip, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Tooltip, Typography } from "@rebasepro/ui";
16
+ import { Formex, FormexController, useCreateFormex } from "@rebasepro/formex";
18
17
 
19
18
  import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
20
- import { z } from "zod";
21
- import {
22
- flattenKeys,
23
- getEntityFromCache,
24
- removeEntityFromCache,
25
- removeEntityFromMemoryCache,
26
- saveEntityToCache
27
- } from "@rebasepro/core";
19
+ import { flattenKeys } from "@rebasepro/core";
28
20
  import { ErrorFocus } from "./components/ErrorFocus";
29
21
  import { CustomFieldValidator, getEntitySchema } from "./validation";
30
22
  import { EntityFormActions } from "./EntityFormActions";
@@ -32,115 +24,21 @@ import type { EntityFormActionsProps } from "../types/components/EntityFormActio
32
24
  import { LocalChangesMenu } from "./components/LocalChangesMenu";
33
25
 
34
26
  import { getEntityTitlePropertyKey, resolveTitleToString } from "../util/previews";
35
- import { getValueInPath, isObject, mergeDeep } from "@rebasepro/utils";
36
- import { useCollectionRegistryController, useSideEntityController, useCMSContext } from "../index";
37
-
38
- // extract touched values for nested touched trees and map to current values
39
- export function extractTouchedValues(values: unknown, touched: Record<string, boolean>): Record<string, unknown> {
40
- let acc: Record<string, unknown> = {};
41
- if (!touched || typeof touched !== "object") {
42
- return acc;
43
- }
44
-
45
- Object.entries(touched).forEach(([key, value]) => {
46
- if (value) {
47
- acc = setIn(acc, key, getIn(values, key)) as Record<string, unknown>;
48
- }
49
- })
50
-
51
- return acc;
52
- }
27
+ import { getValueInPath, mergeDeep } from "@rebasepro/utils";
28
+ import {
29
+ getChanges,
30
+ zodToFormErrors
31
+ } from "./form_utils";
53
32
 
54
33
  /**
55
- * Recursively removes empty plain objects `{}` and empty arrays `[]` from a value tree.
56
- * This prevents ghost containers created by `setIn` intermediate path construction
57
- * (e.g. `{ address: {} }` when only `address.city` was touched but value is undefined)
58
- * from falsely triggering the unsaved local changes indicator.
59
- */
60
- /**
61
- * CheckIcon if a value is semantically empty (null, undefined, or empty string).
34
+ * Headless entity form component.
35
+ *
36
+ * Renders a form for an entity collection without any CMS or backend dependencies.
37
+ * All backend concerns (save, caching, analytics, plugin slots) are provided via
38
+ * callback props. For CMS-connected usage, use {@link EntityFormBinding} instead.
39
+ *
40
+ * @group Components
62
41
  */
63
- function isSemanticEmpty(v: unknown): boolean {
64
- return v === null || v === undefined || v === "";
65
- }
66
-
67
- function removeEmptyContainers(obj: unknown): unknown {
68
- if (Array.isArray(obj)) {
69
- const cleaned = obj.map(removeEmptyContainers);
70
- // Keep arrays even if they contain only nulls/undefined — that's intentional data
71
- return cleaned;
72
- }
73
- if (obj && typeof obj === "object" && Object.getPrototypeOf(obj) === Object.prototype) {
74
- const result: Record<string, unknown> = {};
75
- for (const key of Object.keys(obj)) {
76
- const cleaned = removeEmptyContainers((obj as Record<string, unknown>)[key]);
77
- // Skip empty plain objects
78
- if (cleaned && typeof cleaned === "object" && !Array.isArray(cleaned)
79
- && Object.getPrototypeOf(cleaned) === Object.prototype
80
- && Object.keys(cleaned).length === 0) {
81
- continue;
82
- }
83
- result[key] = cleaned;
84
- }
85
- // After cleaning, check if all remaining values are semantically empty
86
- // (null, undefined, or ""). This catches ghost objects like {type: "", value: null}
87
- // created by oneOf block initialization that aren't meaningful changes.
88
- if (Object.keys(result).length > 0 && Object.values(result).every(isSemanticEmpty)) {
89
- return {};
90
- }
91
- return result;
92
- }
93
- return obj;
94
- }
95
-
96
- export function getChanges<T extends object>(source: Partial<T>, comparison: Partial<T>): Partial<T> {
97
- const changes: Partial<T> = {};
98
-
99
- if (!source) {
100
- return {};
101
- }
102
- if (!comparison) {
103
- return source;
104
- }
105
-
106
- const allKeys = Array.from(new Set([...Object.keys(source), ...Object.keys(comparison)]));
107
-
108
- for (const key of allKeys) {
109
- const sourceValue = (source as Record<string, unknown>)[key];
110
- const comparisonValue = (comparison as Record<string, unknown>)[key];
111
-
112
- if (equal(sourceValue, comparisonValue)) {
113
- continue;
114
- }
115
-
116
- const sourceHasKey = source && typeof source === "object" && Object.prototype.hasOwnProperty.call(source, key);
117
- const comparisonHasKey = comparison && typeof comparison === "object" && Object.prototype.hasOwnProperty.call(comparison, key);
118
-
119
- if (comparisonHasKey && !sourceHasKey) {
120
- (changes as Record<string, unknown>)[key] = undefined;
121
- } else if (Array.isArray(sourceValue)) {
122
- const comparisonArray = Array.isArray(comparisonValue) ? comparisonValue : [];
123
- if (sourceValue.length !== comparisonArray.length) {
124
- (changes as Record<string, unknown>)[key] = sourceValue;
125
- continue;
126
- }
127
- const hasChanges = sourceValue.some((item, index) => !equal(item, comparisonArray[index]));
128
- if (hasChanges) {
129
- (changes as Record<string, unknown>)[key] = sourceValue;
130
- }
131
- } else if (isObject(sourceValue) && sourceValue && isObject(comparisonValue) && comparisonValue) {
132
- const nestedChanges = getChanges(sourceValue, comparisonValue);
133
- if (Object.keys(nestedChanges).length > 0) {
134
- (changes as Record<string, unknown>)[key] = nestedChanges;
135
- }
136
- } else {
137
- (changes as Record<string, unknown>)[key] = sourceValue;
138
- }
139
- }
140
-
141
- return changes;
142
- }
143
-
144
42
  export function EntityForm<M extends Record<string, unknown>>({
145
43
  path,
146
44
  entityId: entityIdProp,
@@ -164,26 +62,33 @@ export function EntityForm<M extends Record<string, unknown>>({
164
62
  showDefaultActions = true,
165
63
  showEntityPath = true,
166
64
  navigateBack: navigateBackProp,
167
- children
65
+ children,
66
+ // Headless callback props
67
+ onSubmit: onSubmitProp,
68
+ onValuesChangeDeferred: onValuesChangeDeferredProp,
69
+ onReset: onResetProp,
70
+ uniqueFieldValidator: uniqueFieldValidatorProp,
71
+ // Slots
72
+ beforeFields,
73
+ afterFields,
74
+ pluginActions: pluginActionsProp,
75
+ // Local changes (managed externally)
76
+ computedInitialValues,
77
+ hasLocalChanges: hasLocalChangesProp,
78
+ localChangesData,
79
+ manualApplyLocalChanges,
80
+ localChangesCacheKey,
81
+ onClearLocalChanges
168
82
  }: EntityFormProps<M>) {
169
- const { t } = useTranslation();
170
- const sideEntityController = useSideEntityController();
171
- const collectionRegistryController = useCollectionRegistryController();
83
+
84
+ const customizationController = useCustomizationController();
172
85
 
173
86
  const navigateBack = useCallback(() => {
174
87
  if (navigateBackProp) {
175
88
  navigateBackProp();
176
- return;
177
- }
178
- if (openEntityMode === "side_panel" || openEntityMode === "dialog") {
179
- // If we are in side panel mode or dialog mode, we close the panel
180
- sideEntityController.close();
181
- } else {
182
- window.history.back();
183
89
  }
184
- }, [navigateBackProp, openEntityMode, sideEntityController]);
90
+ }, [navigateBackProp]);
185
91
 
186
- const authController = useAuthController();
187
92
  const [status, setStatus] = useState<EntityStatus>(initialStatus);
188
93
 
189
94
  const updateStatus = (status: EntityStatus) => {
@@ -193,21 +98,13 @@ export function EntityForm<M extends Record<string, unknown>>({
193
98
 
194
99
  const [valuesToBeSaved, setValuesToBeSaved] = useState<EntityValues<M> | undefined>(undefined);
195
100
  useDebouncedCallback(valuesToBeSaved, () => {
196
- if (valuesToBeSaved)
197
- saveEntity({
198
- entityId: entityIdProp,
199
- collection,
200
- path,
201
- values: valuesToBeSaved
202
- });
101
+ if (valuesToBeSaved && onSubmitProp) {
102
+ setIsSavingAutoSave(true);
103
+ Promise.resolve(onSubmitProp(valuesToBeSaved, formex))
104
+ .finally(() => setIsSavingAutoSave(false));
105
+ }
203
106
  }, false, 2000);
204
107
 
205
- const dataClient = useData();
206
- const snackbarController = useSnackbarController();
207
- const customizationController = useCustomizationController();
208
- const context = useCMSContext();
209
- const analyticsController = useAnalyticsController();
210
-
211
108
  const [underlyingChanges] = useState<Partial<EntityValues<M>>>({});
212
109
 
213
110
  const initialEntityId: string | number | undefined = useMemo(() => {
@@ -221,20 +118,29 @@ export function EntityForm<M extends Record<string, unknown>>({
221
118
  const [entityId, setEntityId] = useState<string | number | undefined>(initialEntityId);
222
119
  const [entityIdError, setEntityIdError] = useState<boolean>(false);
223
120
  const [savingError, setSavingError] = useState<Error | undefined>();
121
+ const [isSavingAutoSave, setIsSavingAutoSave] = useState<boolean>(false);
122
+ const [discardDialogOpen, setDiscardDialogOpen] = useState<boolean>(false);
224
123
 
225
124
  const autoSave = collection.formAutoSave;
226
125
 
227
- const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
228
-
229
- const localChangesDataRaw = useMemo(() => entityId
230
- ? getEntityFromCache(path + "/" + entityId)
231
- : getEntityFromCache(path + "#new"), [entityId, path]);
126
+ // Use externally computed initial values if provided, otherwise compute from entity + collection
127
+ const baseInitialValues = useMemo(() => {
128
+ if (computedInitialValues !== undefined) {
129
+ return computedInitialValues;
130
+ }
131
+ // Fallback: compute from entity/collection (requires authController from context)
132
+ // In headless mode without computedInitialValues, use entity values or defaults
133
+ if ((status === "existing" || status === "copy") && entity) {
134
+ return entity.values ?? getDefaultValuesFor(collection.properties);
135
+ }
136
+ return getDefaultValuesFor(collection.properties);
137
+ }, [computedInitialValues, collection.properties, status, entity]);
232
138
 
233
139
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
234
140
 
235
- const localChangesBackup = getLocalChangesBackup(collection);
236
- const autoApplyLocalChanges = localChangesBackup === "auto_apply";
237
- const manualApplyLocalChanges = localChangesBackup === "manual_apply";
141
+ const hasLocalChanges = hasLocalChangesProp !== undefined
142
+ ? (hasLocalChangesProp && !localChangesCleared)
143
+ : false;
238
144
 
239
145
  const onSubmit = (values: EntityValues<M>, formexController: FormexController<EntityValues<M>>) => {
240
146
 
@@ -247,7 +153,13 @@ export function EntityForm<M extends Record<string, unknown>>({
247
153
  throw Error("New FormType added, check EntityForm");
248
154
  }
249
155
 
250
- return save(values)
156
+ if (!onSubmitProp) {
157
+ console.warn("EntityForm: no onSubmit callback provided. Form submission has no effect.");
158
+ formexController.setSubmitting(false);
159
+ return;
160
+ }
161
+
162
+ return Promise.resolve(save(values))
251
163
  ?.then((savedEntity) => {
252
164
  if (savedEntity) {
253
165
  formexController.resetForm({
@@ -255,12 +167,6 @@ export function EntityForm<M extends Record<string, unknown>>({
255
167
  submitCount: 0,
256
168
  touched: {}
257
169
  });
258
- if (!autoSave) {
259
- snackbarController.open({
260
- type: "success",
261
- message: `${collection.singularName ?? collection.name}: ${t("saved_correctly")}`
262
- });
263
- }
264
170
  }
265
171
  })
266
172
  .finally(() => {
@@ -269,27 +175,10 @@ export function EntityForm<M extends Record<string, unknown>>({
269
175
  };
270
176
 
271
177
  const [initialValues, initialDirty] = useMemo(() => {
272
- const initialValuesWithLocalChanges: Partial<M> = autoApplyLocalChanges && localChangesDataRaw ? mergeDeep(baseInitialValues, localChangesDataRaw as Partial<M>) : baseInitialValues;
273
- const initialValues = initialDirtyValues ? mergeDeep(initialValuesWithLocalChanges, initialDirtyValues) : initialValuesWithLocalChanges;
178
+ const initialValues = initialDirtyValues ? mergeDeep(baseInitialValues, initialDirtyValues) : baseInitialValues;
274
179
  const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
275
180
  return [initialValues, initialDirty];
276
- }, [autoApplyLocalChanges, localChangesDataRaw, baseInitialValues, initialDirtyValues]);
277
-
278
- const localChangesData = useMemo(() => {
279
- if (!localChangesDataRaw) {
280
- return undefined;
281
- }
282
- const changes = getChanges(localChangesDataRaw, initialValues);
283
- // Strip ghost empty containers (e.g. {content: {}}) left by intermediate
284
- // path construction so they don't falsely trigger the unsaved-changes banner.
285
- const cleaned = removeEmptyContainers(changes);
286
- if (cleaned && typeof cleaned === "object" && Object.keys(cleaned).length === 0) {
287
- return undefined;
288
- }
289
- return cleaned;
290
- }, [localChangesDataRaw, initialValues]);
291
-
292
- const hasLocalChanges = !localChangesCleared && !!localChangesData && Object.keys(localChangesData as object).length > 0;
181
+ }, [baseInitialValues, initialDirtyValues]);
293
182
 
294
183
  const internalFormex = useCreateFormex<M>({
295
184
  initialValues: initialValues as M,
@@ -304,21 +193,10 @@ export function EntityForm<M extends Record<string, unknown>>({
304
193
  : {},
305
194
  onSubmit,
306
195
  onReset: () => {
307
- clearDirtyCache();
196
+ onResetProp?.();
308
197
  onValuesModified?.(false, initialValues as M);
309
198
  },
310
- onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
311
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
312
- if (controller.dirty) {
313
- const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
314
- if (touchedValues && Object.keys(touchedValues).length > 0) {
315
- saveEntityToCache(key, touchedValues);
316
- } else {
317
- // Clean up ghost cache entries that contain only empty containers
318
- removeEntityFromCache(key);
319
- }
320
- }
321
- },
199
+ onValuesChangeDeferred: onValuesChangeDeferredProp,
322
200
  validation: async (values): Promise<Record<string, string>> => {
323
201
  if (!validationSchema) return {};
324
202
  const result = await validationSchema.safeParseAsync(values);
@@ -335,8 +213,12 @@ export function EntityForm<M extends Record<string, unknown>>({
335
213
  const isRedo =
336
214
  ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "z") ||
337
215
  ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "y");
216
+ const isSave = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s";
338
217
 
339
- if (isUndo && formex.canUndo) {
218
+ if (isSave && !disabled && formex.dirty) {
219
+ e.preventDefault();
220
+ formex.handleSubmit();
221
+ } else if (isUndo && formex.canUndo) {
340
222
  e.preventDefault();
341
223
  formex.undo();
342
224
  } else if (isRedo && formex.canRedo) {
@@ -350,35 +232,8 @@ export function EntityForm<M extends Record<string, unknown>>({
350
232
 
351
233
  }, [formex]);
352
234
 
353
- const beforeSaveHookError = useCallback((e: Error) => {
354
- snackbarController.open({
355
- type: "error",
356
- message: `${t("error_before_saving")}: ${e?.message}`
357
- });
358
- console.error(e);
359
- }, [snackbarController]);
360
-
361
- const afterSaveHookError = useCallback((e: Error) => {
362
- snackbarController.open({
363
- type: "error",
364
- message: `${t("error_after_saving")}: ${e?.message}`
365
- });
366
- console.error(e);
367
- }, [snackbarController]);
368
-
369
- function clearDirtyCache() {
370
- if (status === "new" || status === "copy") {
371
- removeEntityFromMemoryCache(path + "#new");
372
- removeEntityFromCache(path + "#new");
373
- } else {
374
- removeEntityFromMemoryCache(path + "/" + entityId);
375
- removeEntityFromCache(path + "/" + entityId);
376
- }
377
- }
378
-
379
235
  const afterSave = (updatedEntity: Entity<M>) => {
380
-
381
- clearDirtyCache();
236
+ onResetProp?.();
382
237
  onValuesModified?.(false, updatedEntity.values);
383
238
  onEntityChange?.(updatedEntity);
384
239
  updateStatus("existing");
@@ -395,76 +250,6 @@ export function EntityForm<M extends Record<string, unknown>>({
395
250
  }
396
251
  };
397
252
 
398
- const afterSaveError = useCallback((e: Error) => {
399
- snackbarController.open({
400
- type: "error",
401
- title: t("error_saving_entity"),
402
- message: e?.message
403
- });
404
- console.error("Error saving entity", path, entityId, e);
405
- console.error(e);
406
- }, [entityId, path, snackbarController]);
407
-
408
- const saveEntity = ({
409
- values,
410
- previousValues,
411
- entityId,
412
- collection,
413
- path
414
- }: {
415
- collection: EntityCollection<M>,
416
- path: string,
417
- entityId: string | number | undefined,
418
- values: M,
419
- previousValues?: M,
420
- }): Promise<Entity<M>> => {
421
- return saveEntityWithCallbacks({
422
- path: path,
423
- entityId,
424
- values,
425
- previousValues,
426
- collection,
427
- status,
428
- data: dataClient,
429
- context,
430
- afterSave,
431
- afterSaveError
432
- });
433
- };
434
-
435
- type EntityFormSaveParams<M extends Record<string, unknown>> = {
436
- collection: EntityCollection<M>,
437
- path: string,
438
- entityId: string | number | undefined,
439
- values: EntityValues<M>,
440
- previousValues?: EntityValues<M>,
441
- autoSave: boolean
442
- };
443
-
444
- const onSaveEntityRequest = async ({
445
- collection,
446
- path,
447
- entityId,
448
- values,
449
- previousValues,
450
- autoSave
451
- }: EntityFormSaveParams<M>): Promise<Entity<M> | void> => {
452
- if (!status)
453
- return;
454
- if (autoSave) {
455
- setValuesToBeSaved(values);
456
- return Promise.resolve();
457
- } else {
458
- return saveEntity({
459
- collection,
460
- path,
461
- entityId,
462
- values,
463
- previousValues
464
- });
465
- }
466
- };
467
-
468
253
  const lastSavedValues = useRef<EntityValues<M> | undefined>(entity?.values);
469
254
  const save = async (values: EntityValues<M>): Promise<Entity<M> | void> => {
470
255
  const valuesToSave = status === "existing"
@@ -476,29 +261,34 @@ export function EntityForm<M extends Record<string, unknown>>({
476
261
  }
477
262
 
478
263
  lastSavedValues.current = values;
479
- return onSaveEntityRequest({
480
- collection,
481
- path,
482
- entityId,
483
- values: valuesToSave,
484
- previousValues: entity?.values,
485
- autoSave: autoSave ?? false
486
- }).then((savedEntity) => {
487
- const eventName: AnalyticsEvent = status === "new"
488
- ? "new_entity_saved"
489
- : (status === "copy" ? "entity_copied" : (status === "existing" ? "entity_edited" : "unmapped_event"));
490
- analyticsController.onAnalyticsEvent?.(eventName, { path });
491
- return savedEntity;
492
- }).catch(e => {
493
- console.error(e);
494
- setSavingError(e);
495
- });
264
+
265
+ if (!onSubmitProp) {
266
+ return;
267
+ }
268
+
269
+ const autoSaveEnabled = autoSave ?? false;
270
+
271
+ if (autoSaveEnabled) {
272
+ setValuesToBeSaved(values);
273
+ return Promise.resolve();
274
+ }
275
+
276
+ return Promise.resolve(onSubmitProp(valuesToSave as M, formex))
277
+ .then((result) => {
278
+ if (result) {
279
+ afterSave(result as Entity<M>);
280
+ }
281
+ return result as Entity<M> | void;
282
+ })
283
+ .catch(e => {
284
+ console.error(e);
285
+ setSavingError(e);
286
+ });
496
287
  };
497
288
 
498
289
  const disabled = formex.isSubmitting || Boolean(disabledProp);
499
290
 
500
291
  const formContext: FormContext<M> = {
501
-
502
292
  setFieldValue: useCallback((key: string, value: unknown) => formex.setFieldValue(key, value), []),
503
293
  values: formex.values,
504
294
  collection,
@@ -519,24 +309,6 @@ export function EntityForm<M extends Record<string, unknown>>({
519
309
  }, [formex.version, collection, entityId, path]);
520
310
 
521
311
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
522
- const parentCollectionSlugs = collectionRegistryController.getParentCollectionSlugs(path);
523
- const parentEntityIds = collectionRegistryController.getParentEntityIds(path);
524
-
525
- const formActionProps: PluginFormActionProps = {
526
- entityId,
527
- parentCollectionSlugs,
528
- parentEntityIds,
529
- path: path,
530
- status,
531
- collection: collection as EntityCollection,
532
- context,
533
- formContext: formContext as FormContext<Record<string, unknown>>,
534
- openEntityMode,
535
- disabled: actionsDisabled
536
- };
537
- const pluginFormActions = useSlot("form.actions", formActionProps);
538
- const pluginFormBefore = useSlot("form.before", formActionProps);
539
- const pluginFormAfter = useSlot("form.after", formActionProps);
540
312
 
541
313
  const titlePropertyKey = getEntityTitlePropertyKey(collection, customizationController.propertyConfigs);
542
314
  const rawTitle = formex.values && titlePropertyKey ? getValueInPath(formex.values, titlePropertyKey) : undefined;
@@ -544,31 +316,23 @@ parentEntityIds,
544
316
  ? resolveTitleToString(rawTitle)
545
317
  : (collection.singularName ?? collection.name);
546
318
 
319
+ const modified = formex.dirty;
320
+
547
321
  useEffect(() => {
548
322
  if (!autoSave) {
549
323
  onValuesModified?.(modified, formex.values);
550
324
  }
551
325
  }, [formex.dirty]);
552
326
 
553
- const modified = formex.dirty;
327
+ // Default no-op unique field validator — always passes
328
+ const defaultUniqueFieldValidator: CustomFieldValidator = useCallback(async () => true, []);
554
329
 
555
- const uniqueFieldValidator: CustomFieldValidator = useCallback(async ({
556
- name,
557
- value
558
- }) => {
559
- try {
560
- const accessor = dataClient.collection(path);
561
- const { data } = await accessor.find({
562
- where: { [name]: `eq.${value}` },
563
- limit: 2
564
- });
565
- const otherEntities = entityId ? data.filter(e => e.id !== entityId) : data;
566
- return otherEntities.length === 0;
567
- } catch (e: unknown) {
568
- console.error("Error checking unique field", e);
569
- return true;
330
+ const uniqueFieldValidator: CustomFieldValidator = useMemo(() => {
331
+ if (uniqueFieldValidatorProp) {
332
+ return uniqueFieldValidatorProp as CustomFieldValidator;
570
333
  }
571
- }, [dataClient, path, entityId]);
334
+ return defaultUniqueFieldValidator;
335
+ }, [uniqueFieldValidatorProp, defaultUniqueFieldValidator]);
572
336
 
573
337
  const validationSchema = useMemo(() => getEntitySchema(
574
338
  entityId,
@@ -580,7 +344,7 @@ parentEntityIds,
580
344
 
581
345
  useEffect(() => {
582
346
  if (!autoSave && !formex.isSubmitting && underlyingChanges && entity) {
583
- // we update the form fields from the Firestore data
347
+ // we update the form fields from the driver data
584
348
  // if they were not touched
585
349
  Object.entries(underlyingChanges).forEach(([key, value]) => {
586
350
  const formValue = formex.values[key];
@@ -604,6 +368,9 @@ parentEntityIds,
604
368
  formContext={formContext}
605
369
  />;
606
370
  }
371
+ const isNewEntity = status === "new" || status === "copy";
372
+ let firstFocusableIndex = -1;
373
+
607
374
  return (
608
375
  <FormLayout>
609
376
  {formFieldKeys.map((key) => {
@@ -621,6 +388,10 @@ parentEntityIds,
621
388
  const hidden = isHidden(property);
622
389
  if (hidden) return null;
623
390
  const widthPercentage = property.ui?.widthPercentage ?? 100;
391
+
392
+ const shouldAutoFocus = isNewEntity && !disabled && firstFocusableIndex === -1;
393
+ if (shouldAutoFocus) firstFocusableIndex = 0;
394
+
624
395
  const cmsFormFieldProps: PropertyFieldBindingProps<M> = {
625
396
  propertyKey: key,
626
397
  disabled,
@@ -630,7 +401,7 @@ parentEntityIds,
630
401
  context: formContext,
631
402
  partOfArray: false,
632
403
  minimalistView: false,
633
- autoFocus: false
404
+ autoFocus: shouldAutoFocus
634
405
  };
635
406
 
636
407
  return (
@@ -644,16 +415,17 @@ parentEntityIds,
644
415
 
645
416
  const additionalField = collection.additionalFields?.find(f => f.key === key);
646
417
  if (additionalField && entity) {
647
- const Builder = additionalField.Builder;
648
- if (!Builder && !additionalField.value) {
418
+ const AdditionalFieldBuilder = additionalField.Builder;
419
+ if (!AdditionalFieldBuilder && !additionalField.value) {
649
420
  throw new Error("When using additional fields you need to provide a Builder or a value");
650
421
  }
651
- const child = Builder
652
- ? <Builder entity={entity} context={context}/>
422
+ const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps['context'];
423
+ const child = AdditionalFieldBuilder
424
+ ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
653
425
  : <div className={"w-full"}>
654
426
  {additionalField.value?.({
655
427
  entity,
656
- context
428
+ context: additionalFieldContext
657
429
  })?.toString()}
658
430
  </div>;
659
431
 
@@ -683,9 +455,11 @@ parentEntityIds,
683
455
 
684
456
  const formRef = useRef<HTMLDivElement>(null);
685
457
 
458
+ const hasFormErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
459
+
686
460
  const formView = <ErrorBoundary>
687
461
  <>
688
- {pluginFormBefore}
462
+ {beforeFields}
689
463
 
690
464
  {!Builder && <div className={"w-full flex flex-col items-start my-4 lg:my-6"}>
691
465
  <Typography
@@ -696,7 +470,7 @@ parentEntityIds,
696
470
 
697
471
  {!entity?.values && initialStatus === "existing" &&
698
472
  <Alert color={"warning"} size={"small"} outerClassName={"w-full mb-4 text-xs"}>
699
- {t("entity_does_not_exist")}
473
+ This entity does not exist yet
700
474
  </Alert>}
701
475
 
702
476
  {showEntityPath && <Alert color={"base"} outerClassName={"w-full"} size={"small"}>
@@ -710,7 +484,11 @@ parentEntityIds,
710
484
  {children}
711
485
 
712
486
  {initialEntityId && !entity && initialStatus !== "new" && <Alert color={"info"} size={"small"}>
713
- {t("entity_does_not_exist")}
487
+ This entity does not exist yet
488
+ </Alert>}
489
+
490
+ {hasFormErrors && <Alert color={"error"} size={"small"} outerClassName={"w-full mt-2"}>
491
+ Please fix the highlighted errors before saving.
714
492
  </Alert>}
715
493
 
716
494
  {formContext && <>
@@ -720,9 +498,31 @@ parentEntityIds,
720
498
  </div>
721
499
  </>}
722
500
 
723
- {pluginFormAfter}
501
+ {afterFields}
724
502
 
725
503
  {forceActionsAtTheBottom && <div className="h-16"/>}
504
+
505
+ <Dialog open={discardDialogOpen} onOpenChange={setDiscardDialogOpen} maxWidth={"sm"}>
506
+ <DialogTitle>{status === "existing" ? "Discard changes?" : "Clear form?"}</DialogTitle>
507
+ <DialogContent>
508
+ <Typography>
509
+ {status === "existing"
510
+ ? "All unsaved changes will be lost. This cannot be undone."
511
+ : "All entered values will be cleared. This cannot be undone."}
512
+ </Typography>
513
+ </DialogContent>
514
+ <DialogActions>
515
+ <Button variant={"text"} onClick={() => setDiscardDialogOpen(false)}>
516
+ Cancel
517
+ </Button>
518
+ <Button variant={"filled"} color={"error"} onClick={() => {
519
+ setDiscardDialogOpen(false);
520
+ formex.resetForm({ values: baseInitialValues as M });
521
+ }}>
522
+ {status === "existing" ? "Discard" : "Clear"}
523
+ </Button>
524
+ </DialogActions>
525
+ </Dialog>
726
526
  </>
727
527
  </ErrorBoundary>;
728
528
 
@@ -746,7 +546,7 @@ parentEntityIds,
746
546
  formex={formex as FormexController<Record<string, unknown>>}
747
547
  disabled={actionsDisabled}
748
548
  status={status}
749
- pluginActions={pluginFormActions ?? []}
549
+ pluginActions={pluginActionsProp ?? []}
750
550
  openEntityMode={openEntityMode}
751
551
  showDefaultActions={showDefaultActions}
752
552
  navigateBack={navigateBack}
@@ -757,9 +557,14 @@ parentEntityIds,
757
557
  <Formex value={formex}>
758
558
  <form
759
559
  onSubmit={formex.handleSubmit}
760
- onReset={() => formex.resetForm({
761
- values: baseInitialValues as M
762
- })}
560
+ onReset={(e) => {
561
+ e.preventDefault();
562
+ if (formex.dirty) {
563
+ setDiscardDialogOpen(true);
564
+ } else {
565
+ formex.resetForm({ values: baseInitialValues as M });
566
+ }
567
+ }}
763
568
  noValidate
764
569
  className={cls("@container flex-1 flex flex-row w-full overflow-y-auto justify-center", className)}>
765
570
  <div
@@ -773,28 +578,37 @@ parentEntityIds,
773
578
  : "pt-12 pb-16 px-4 sm:px-8 md:px-10"
774
579
  )}>
775
580
  <div
776
- className={"flex flex-row gap-4 self-end sticky top-4 z-10"}>
581
+ className={"flex flex-row gap-4 justify-end h-0 overflow-visible sticky top-4 z-10"}>
777
582
 
778
- {manualApplyLocalChanges && hasLocalChanges &&
583
+ {manualApplyLocalChanges && hasLocalChanges && localChangesCacheKey &&
779
584
  <LocalChangesMenu<M>
780
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
585
+ cacheKey={localChangesCacheKey}
781
586
  properties={collection.properties}
782
587
  cachedData={localChangesData as Partial<M>}
783
588
  formex={formex}
784
- onClearLocalChanges={() => setLocalChangesCleared(true)}
589
+ onClearLocalChanges={() => {
590
+ setLocalChangesCleared(true);
591
+ onClearLocalChanges?.();
592
+ }}
785
593
  />}
786
594
 
787
- {formex.dirty
788
- ? <Tooltip title={t("form_modified")}>
789
- <Chip size={"small"} className={"py-1"} colorScheme={"orangeDarker"}>
790
- <PencilIcon size={iconSize.smallest}/>
595
+ {isSavingAutoSave
596
+ ? <Tooltip title={"Saving…"}>
597
+ <Chip size={"small"} className={"py-1"} colorScheme={"blueDarker"}>
598
+ <LoaderIcon size={iconSize.smallest} className={"animate-spin"}/>
791
599
  </Chip>
792
600
  </Tooltip>
793
- : <Tooltip title={t("form_in_sync")}>
794
- <Chip size={"small"} className={"py-1"}>
795
- <CheckIcon size={iconSize.smallest}/>
796
- </Chip>
797
- </Tooltip>}
601
+ : formex.dirty
602
+ ? <Tooltip title={"Form has been modified"}>
603
+ <Chip size={"small"} className={"py-1"} colorScheme={"orangeDarker"}>
604
+ <PencilIcon size={iconSize.smallest}/>
605
+ </Chip>
606
+ </Tooltip>
607
+ : <Tooltip title={"Form is in sync"}>
608
+ <Chip size={"small"} className={"py-1"}>
609
+ <CheckIcon size={iconSize.smallest}/>
610
+ </Chip>
611
+ </Tooltip>}
798
612
  </div>
799
613
 
800
614
  {formView}
@@ -811,57 +625,7 @@ parentEntityIds,
811
625
  );
812
626
  }
813
627
 
814
- export function getInitialEntityValues<M extends Record<string, unknown>>(
815
- authController: AuthController,
816
- collection: EntityCollection,
817
- path: string,
818
- status: "new" | "existing" | "copy",
819
- entity: Entity<M> | undefined,
820
- propertyConfigs?: Record<string, PropertyConfig>
821
- ): Partial<EntityValues<M>> {
822
- const properties = collection.properties;
823
- if ((status === "existing" || status === "copy") && entity) {
824
- let values: Partial<EntityValues<M>>;
825
- if (!collection.alwaysApplyDefaultValues) {
826
- values = entity.values ?? getDefaultValuesFor(properties);
827
- } else {
828
- const defaultValues = getDefaultValuesFor(properties);
829
- values = mergeDeep(defaultValues, entity.values ?? {});
830
- }
831
- // When copying, clear ID fields so the database generates new IDs
832
- if (status === "copy") {
833
- const result = { ...values };
834
- for (const [key, property] of Object.entries(properties)) {
835
- if (property && "isId" in property && property.isId) {
836
- delete (result as Record<string, unknown>)[key];
837
- }
838
- }
839
- return result;
840
- }
841
- return values;
842
- } else if (status === "new") {
843
- return getDefaultValuesFor(properties);
844
- } else {
845
- console.error({
846
- status,
847
- entity
848
- });
849
- throw new Error("Form has not been initialised with the correct parameters");
850
- }
851
- }
852
-
853
- export function zodToFormErrors(zodError: z.ZodError): Record<string, string> {
854
- let errors: Record<string, string> = {};
855
- for (const issue of zodError.issues) {
856
- const path = issue.path.join(".");
857
- if (path && !getIn(errors, path)) {
858
- errors = setIn(errors, path, issue.message) as Record<string, string>;
859
- }
860
- }
861
- return errors;
862
- }
863
-
864
- function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<any>, lastSavedValues: React.MutableRefObject<any>, save: (values: EntityValues<any>) => Promise<Entity<any> | void>) {
628
+ function useOnAutoSave<M extends Record<string, unknown>>(autoSave: undefined | boolean, formex: FormexController<M>, lastSavedValues: React.MutableRefObject<EntityValues<M> | undefined>, save: (values: EntityValues<M>) => Promise<Entity<M> | void>) {
865
629
  useEffect(() => {
866
630
  if (!autoSave) return;
867
631
  if (formex.values && !equal(formex.values, lastSavedValues.current)) {
@@ -869,4 +633,3 @@ function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<a
869
633
  }
870
634
  }, [autoSave, formex.values]);
871
635
  }
872
-