@firecms/core 3.0.0-canary.283 → 3.0.0-canary.284

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.
@@ -307,6 +307,13 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
307
307
  * This prop has no effect if the history plugin is not enabled
308
308
  */
309
309
  history?: boolean;
310
+ /**
311
+ * If set to true, local changes to entities in this collection will be backed up
312
+ * in the browser's local storage. This allows users to recover unsaved changes
313
+ * in case of accidental navigation or browser crashes.
314
+ * Defaults to `true`.
315
+ */
316
+ enableLocalChangesBackup?: boolean;
310
317
  }
311
318
  /**
312
319
  * Parameter passed to the `Actions` prop in the collection configuration.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.0.0-canary.283",
4
+ "version": "3.0.0-canary.284",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,9 +53,9 @@
53
53
  "@dnd-kit/core": "^6.3.1",
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
- "@firecms/editor": "^3.0.0-canary.283",
57
- "@firecms/formex": "^3.0.0-canary.283",
58
- "@firecms/ui": "^3.0.0-canary.283",
56
+ "@firecms/editor": "^3.0.0-canary.284",
57
+ "@firecms/formex": "^3.0.0-canary.284",
58
+ "@firecms/ui": "^3.0.0-canary.284",
59
59
  "@radix-ui/react-portal": "^1.1.9",
60
60
  "clsx": "^2.1.1",
61
61
  "date-fns": "^3.6.0",
@@ -108,7 +108,7 @@
108
108
  "dist",
109
109
  "src"
110
110
  ],
111
- "gitHead": "521154e8ee22424451421052cbc22fa6ba5b4d4d",
111
+ "gitHead": "443c1f2616b08bf03cd00ce2932be53d3aa96110",
112
112
  "publishConfig": {
113
113
  "access": "public"
114
114
  },
@@ -97,9 +97,13 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
97
97
  useCache: false
98
98
  });
99
99
 
100
- const cachedValues = entityId
101
- ? getEntityFromCache(props.path + "/" + entityId)
102
- : getEntityFromCache(props.path + "#new");
100
+ const enableLocalChangesBackup = props.collection.enableLocalChangesBackup !== undefined ? props.collection.enableLocalChangesBackup : true;
101
+
102
+ const initialDirtyValues = enableLocalChangesBackup
103
+ ? (entityId
104
+ ? getEntityFromCache(props.path + "/" + entityId)
105
+ : getEntityFromCache(props.path + "#new"))
106
+ : undefined;
103
107
 
104
108
  const authController = useAuthController();
105
109
 
@@ -114,18 +118,18 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
114
118
  }
115
119
  }, [authController, entity, status]);
116
120
 
117
- if ((dataLoading && !cachedValues) || (!entity || canEdit === undefined) && (status === "existing" || status === "copy")) {
121
+ if ((dataLoading && !initialDirtyValues) || (!entity || canEdit === undefined) && (status === "existing" || status === "copy")) {
118
122
  return <CircularProgressCenter/>;
119
123
  }
120
124
 
121
- if (entityId && !entity && !cachedValues) {
125
+ if (entityId && !entity && !initialDirtyValues) {
122
126
  console.error(`Entity with id ${entityId} not found in collection ${props.path}`);
123
127
  }
124
128
 
125
129
  return <EntityEditViewInner<M> {...props}
126
130
  entityId={entityId}
127
131
  entity={entity}
128
- cachedDirtyValues={cachedValues as Partial<M>}
132
+ initialDirtyValues={initialDirtyValues as Partial<M>}
129
133
  dataLoading={dataLoading}
130
134
  status={status}
131
135
  setStatus={setStatus}
@@ -144,7 +148,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
144
148
  onSaved,
145
149
  onTabChange,
146
150
  entity,
147
- cachedDirtyValues,
151
+ initialDirtyValues,
148
152
  dataLoading,
149
153
  layout = "side_panel",
150
154
  barActions,
@@ -154,7 +158,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
154
158
  canEdit
155
159
  }: EntityEditViewProps<M> & {
156
160
  entity?: Entity<M>,
157
- cachedDirtyValues?: Partial<M>, // dirty cached entity in memory
161
+ initialDirtyValues?: Partial<M>, // dirty cached entity in memory
158
162
  dataLoading: boolean,
159
163
  status: EntityStatus,
160
164
  setStatus: (status: EntityStatus) => void,
@@ -379,7 +383,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
379
383
  entityId={entityId ?? usedEntity?.id}
380
384
  onValuesModified={onValuesModified}
381
385
  entity={entity}
382
- initialDirtyValues={cachedDirtyValues}
386
+ initialDirtyValues={initialDirtyValues}
383
387
  openEntityMode={layout}
384
388
  forceActionsAtTheBottom={actionsAtTheBottom}
385
389
  initialStatus={status}
@@ -523,4 +527,3 @@ export function EntityEditViewInner<M extends Record<string, any>>({
523
527
 
524
528
  return result;
525
529
  }
526
-
@@ -17,6 +17,7 @@ import {
17
17
  cls,
18
18
  defaultBorderMixin,
19
19
  DialogActions,
20
+ ErrorIcon,
20
21
  IconButton,
21
22
  LoadingButton,
22
23
  Tooltip,
@@ -31,6 +32,8 @@ import {
31
32
  } from "../hooks";
32
33
  import { EntityFormActionsProps } from "../form/EntityFormActions";
33
34
  import { SideDialogController, useSideDialogContext } from "./SideDialogs";
35
+ import { FormexController } from "@firecms/formex";
36
+ import { ErrorTooltip } from "../components/ErrorTooltip";
34
37
 
35
38
  export function EntityEditViewFormActions({
36
39
  collection,
@@ -80,14 +83,14 @@ export function EntityEditViewFormActions({
80
83
  collection,
81
84
  context,
82
85
  sideEntityController,
83
- isSubmitting: formex.isSubmitting,
84
86
  disabled,
85
87
  status,
86
88
  sideDialogContext,
87
89
  pluginActions,
88
90
  openEntityMode,
89
91
  navigateBack,
90
- formContext
92
+ formContext,
93
+ formex
91
94
  })
92
95
  : buildSideActions({
93
96
  savingError,
@@ -96,14 +99,14 @@ export function EntityEditViewFormActions({
96
99
  collection,
97
100
  context,
98
101
  sideEntityController,
99
- isSubmitting: formex.isSubmitting,
100
102
  sideDialogContext,
101
103
  disabled,
102
104
  status,
103
105
  pluginActions,
104
106
  openEntityMode,
105
107
  navigateBack,
106
- formContext
108
+ formContext,
109
+ formex
107
110
  });
108
111
  }
109
112
 
@@ -114,14 +117,14 @@ type ActionsViewProps<M extends object> = {
114
117
  collection: ResolvedEntityCollection,
115
118
  context: FireCMSContext,
116
119
  sideEntityController: SideEntityController,
117
- isSubmitting: boolean,
118
120
  disabled: boolean,
119
121
  status: "new" | "existing" | "copy",
120
122
  sideDialogContext: SideDialogController,
121
123
  pluginActions?: React.ReactNode[],
122
124
  openEntityMode: "side_panel" | "full_screen";
123
125
  navigateBack: () => void;
124
- formContext: FormContext
126
+ formContext: FormContext,
127
+ formex: FormexController<any>;
125
128
  };
126
129
 
127
130
  function buildBottomActions<M extends object>({
@@ -131,16 +134,17 @@ function buildBottomActions<M extends object>({
131
134
  collection,
132
135
  context,
133
136
  sideEntityController,
134
- isSubmitting,
135
137
  disabled,
136
138
  status,
137
139
  sideDialogContext,
138
140
  pluginActions,
139
141
  openEntityMode,
140
142
  navigateBack,
141
- formContext
143
+ formContext,
144
+ formex
142
145
  }: ActionsViewProps<M>) {
143
146
 
147
+ const hasErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
144
148
  const canClose = openEntityMode === "side_panel";
145
149
  return <DialogActions
146
150
  position={"absolute"}>
@@ -179,16 +183,20 @@ function buildBottomActions<M extends object>({
179
183
 
180
184
  {pluginActions}
181
185
 
186
+ {hasErrors ?
187
+ <ErrorTooltip title={"This form has errors"}>
188
+ <ErrorIcon className="ml-4" color="error" size={"smallest"}/>
189
+ </ErrorTooltip> : null}
182
190
  <Button variant="text"
183
191
  color="primary"
184
- disabled={disabled || isSubmitting}
192
+ disabled={disabled || formex.isSubmitting}
185
193
  type="reset">
186
194
  {status === "existing" ? "Discard" : "Clear"}
187
195
  </Button>
188
196
  <Button variant={canClose ? "text" : "filled"}
189
197
  color="primary"
190
198
  type="submit"
191
- disabled={disabled || isSubmitting}
199
+ disabled={disabled || formex.isSubmitting}
192
200
  onClick={() => {
193
201
  sideDialogContext.setPendingClose(false);
194
202
  }}>
@@ -199,7 +207,7 @@ function buildBottomActions<M extends object>({
199
207
  {canClose && <LoadingButton variant="filled"
200
208
  color="primary"
201
209
  type="submit"
202
- loading={isSubmitting}
210
+ loading={formex.isSubmitting}
203
211
  disabled={disabled}
204
212
  onClick={() => {
205
213
  sideDialogContext.setPendingClose?.(true);
@@ -218,28 +226,35 @@ function buildSideActions<M extends object>({
218
226
  collection,
219
227
  context,
220
228
  sideEntityController,
221
- isSubmitting,
222
229
  disabled,
223
230
  status,
224
231
  sideDialogContext,
225
232
  pluginActions,
226
233
  openEntityMode,
227
234
  navigateBack,
228
- formContext
235
+ formContext,
236
+ formex
229
237
  }: ActionsViewProps<M>) {
230
238
 
239
+ const hasErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
231
240
  return <div
232
241
  className={cls("overflow-auto h-full flex flex-col gap-2 w-80 2xl:w-96 px-4 py-16 sticky top-0 border-l", defaultBorderMixin)}>
233
- <LoadingButton fullWidth={true} variant="filled" color="primary" type="submit" size={"large"}
234
- disabled={disabled || isSubmitting} onClick={() => {
235
- sideDialogContext.setPendingClose?.(false);
236
- }}>
242
+ <LoadingButton fullWidth={true}
243
+ variant="filled"
244
+ color="primary"
245
+ type="submit"
246
+ size={"large"}
247
+ startIcon={hasErrors ? <ErrorIcon/> : undefined}
248
+ disabled={disabled || formex.isSubmitting}
249
+ onClick={() => {
250
+ sideDialogContext.setPendingClose?.(false);
251
+ }}>
237
252
  {status === "existing" && "Save"}
238
253
  {status === "copy" && "Create copy"}
239
254
  {status === "new" && "Create"}
240
255
  </LoadingButton>
241
256
 
242
- <Button fullWidth={true} variant="text" disabled={disabled || isSubmitting} type="reset">
257
+ <Button fullWidth={true} variant="text" disabled={disabled || formex.isSubmitting} type="reset">
243
258
  {status === "existing" ? "Discard" : "Clear"}
244
259
  </Button>
245
260
 
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  AuthController,
4
4
  CMSAnalyticsEvent,
@@ -32,19 +32,20 @@ import {
32
32
  useAuthController,
33
33
  useCustomizationController,
34
34
  useDataSource,
35
- useFireCMSContext, useNavigationController,
35
+ useFireCMSContext,
36
+ useNavigationController,
36
37
  useSideEntityController,
37
38
  useSnackbarController
38
39
  } from "../hooks";
39
40
  import { Alert, CheckIcon, Chip, cls, EditIcon, NotesIcon, paperMixin, Tooltip, Typography } from "@firecms/ui";
40
- import { Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
41
+ import { flattenKeys, Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
41
42
  import { useAnalyticsController } from "../hooks/useAnalyticsController";
42
43
  import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
43
44
  import { ValidationError } from "yup";
44
45
  import { removeEntityFromCache, saveEntityToCache } from "../util/entity_cache";
45
- import { CustomIdField } from "../form/components/CustomIdField";
46
- import { ErrorFocus } from "../form/components/ErrorFocus";
47
- import { CustomFieldValidator, getYupEntitySchema } from "../form/validation";
46
+ import { CustomIdField } from "./components/CustomIdField";
47
+ import { ErrorFocus } from "./components/ErrorFocus";
48
+ import { CustomFieldValidator, getYupEntitySchema } from "./validation";
48
49
  import { EntityFormActions, EntityFormActionsProps } from "./EntityFormActions";
49
50
 
50
51
  export type OnUpdateParams = {
@@ -96,6 +97,22 @@ export type EntityFormProps<M extends Record<string, any>> = {
96
97
  children?: React.ReactNode;
97
98
  };
98
99
 
100
+ // extract touched values for nested touched trees and map to current values
101
+ export function extractTouchedValues(values: any, touched: Record<string, boolean>): Record<string, any> {
102
+ let acc: Record<string, any> = {};
103
+ if (!touched || typeof touched !== "object") {
104
+ return acc;
105
+ }
106
+
107
+ Object.entries(touched).forEach(([key, value]) => {
108
+ if (value) {
109
+ acc = setIn(acc, key, getIn(values, key));
110
+ }
111
+ })
112
+
113
+ return acc;
114
+ }
115
+
99
116
  export function EntityForm<M extends Record<string, any>>({
100
117
  path,
101
118
  fullIdPath,
@@ -126,7 +143,6 @@ export function EntityForm<M extends Record<string, any>>({
126
143
  console.warn(`The collection ${collection.path} has customId and formAutoSave enabled. This is not supported and formAutoSave will be ignored`);
127
144
  }
128
145
 
129
-
130
146
  const sideEntityController = useSideEntityController();
131
147
  const navigationController = useNavigationController();
132
148
 
@@ -164,7 +180,7 @@ export function EntityForm<M extends Record<string, any>>({
164
180
  const context = useFireCMSContext();
165
181
  const analyticsController = useAnalyticsController();
166
182
 
167
- const [underlyingChanges, setUnderlyingChanges] = useState<Partial<EntityValues<M>>>({});
183
+ const [underlyingChanges] = useState<Partial<EntityValues<M>>>({});
168
184
 
169
185
  const [customIdLoading, setCustomIdLoading] = useState<boolean>(false);
170
186
 
@@ -226,14 +242,31 @@ export function EntityForm<M extends Record<string, any>>({
226
242
  });
227
243
  };
228
244
 
245
+ const baseInitialValues = getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs);
246
+ const initialValues = initialDirtyValues ? mergeDeep(baseInitialValues, initialDirtyValues) : baseInitialValues;
247
+ const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
229
248
  const formex: FormexController<M> = formexProp ?? useCreateFormex<M>({
230
- initialValues: (initialDirtyValues ?? getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs)) as M,
231
- initialDirty: Boolean(initialDirtyValues),
249
+ initialValues: initialValues as M,
250
+ initialDirty,
251
+ initialTouched: initialDirtyValues ?
252
+ flattenKeys(initialDirtyValues!)
253
+ .reduce((previousValue, currentValue) => ({
254
+ ...previousValue,
255
+ [currentValue]: true
256
+ }), {})
257
+ : {},
232
258
  onSubmit,
233
259
  onReset: () => {
234
260
  clearDirtyCache();
235
261
  onValuesModified?.(false);
236
262
  },
263
+ onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
264
+ const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
265
+ if (controller.dirty) {
266
+ const touchedValues = extractTouchedValues(values, controller.touched);
267
+ saveEntityToCache(key, touchedValues);
268
+ }
269
+ },
237
270
  validation: (values) => {
238
271
  return validationSchema?.validate(values, { abortEarly: false })
239
272
  .then(() => {
@@ -405,7 +438,7 @@ export function EntityForm<M extends Record<string, any>>({
405
438
  values,
406
439
  previousValues: entity?.values,
407
440
  autoSave: autoSave ?? false
408
- }).then((res) => {
441
+ }).then(() => {
409
442
  const eventName: CMSAnalyticsEvent = status === "new"
410
443
  ? "new_entity_saved"
411
444
  : (status === "copy" ? "entity_copied" : (status === "existing" ? "entity_edited" : "unmapped_event"));
@@ -443,7 +476,8 @@ export function EntityForm<M extends Record<string, any>>({
443
476
  type: "error",
444
477
  message: "Error updating id, check the console"
445
478
  });
446
- }, []);
479
+ console.error(error);
480
+ }, [snackbarController]);
447
481
 
448
482
  const pluginActions: React.ReactNode[] = [];
449
483
  const plugins = customizationController.plugins;
@@ -507,13 +541,11 @@ export function EntityForm<M extends Record<string, any>>({
507
541
  }
508
542
  }, [formex.dirty]);
509
543
 
510
- const deferredValues = useDeferredValue(formex.values);
511
544
  const modified = formex.dirty;
512
545
 
513
546
  const uniqueFieldValidator: CustomFieldValidator = useCallback(({
514
547
  name,
515
- value,
516
- property
548
+ value
517
549
  }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
518
550
  [dataSource, path, entityId]);
519
551
 
@@ -525,13 +557,6 @@ export function EntityForm<M extends Record<string, any>>({
525
557
  : undefined,
526
558
  [entityId, resolvedCollection.properties, uniqueFieldValidator]);
527
559
 
528
- useEffect(() => {
529
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
530
- if (modified) {
531
- saveEntityToCache(key, deferredValues);
532
- }
533
- }, [deferredValues, modified, path, entityId, status]);
534
-
535
560
  useOnAutoSave(autoSave, formex, lastSavedValues, save);
536
561
 
537
562
  useEffect(() => {
@@ -716,7 +741,7 @@ export function EntityForm<M extends Record<string, any>>({
716
741
  <form
717
742
  onSubmit={formex.handleSubmit}
718
743
  onReset={() => formex.resetForm({
719
- values: getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs) as M
744
+ values: baseInitialValues as M
720
745
  })}
721
746
  noValidate
722
747
  className={cls("flex-1 flex flex-row w-full overflow-y-auto justify-center", className)}>
@@ -752,7 +777,7 @@ export function EntityForm<M extends Record<string, any>>({
752
777
  );
753
778
  }
754
779
 
755
- function getInitialEntityValues<M extends object>(
780
+ export function getInitialEntityValues<M extends object>(
756
781
  authController: AuthController,
757
782
  collection: EntityCollection,
758
783
  path: string,
@@ -811,4 +836,3 @@ function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<a
811
836
  }
812
837
  }, [formex.values]);
813
838
  }
814
-
@@ -7,7 +7,16 @@ import {
7
7
  ResolvedEntityCollection,
8
8
  SideEntityController
9
9
  } from "../types";
10
- import { Button, cls, defaultBorderMixin, DialogActions, IconButton, LoadingButton, Typography } from "@firecms/ui";
10
+ import {
11
+ Button,
12
+ cls,
13
+ defaultBorderMixin,
14
+ DialogActions,
15
+ ErrorIcon,
16
+ IconButton,
17
+ LoadingButton,
18
+ Typography
19
+ } from "@firecms/ui";
11
20
  import { FormexController } from "@firecms/formex";
12
21
  import { useFireCMSContext, useSideEntityController } from "../hooks";
13
22
 
@@ -57,13 +66,13 @@ export function EntityFormActions({
57
66
  collection,
58
67
  context,
59
68
  sideEntityController,
60
- isSubmitting: formex.isSubmitting,
61
69
  disabled,
62
70
  status,
63
71
  pluginActions,
64
72
  openEntityMode,
65
73
  navigateBack,
66
- formContext
74
+ formContext,
75
+ formex
67
76
  })
68
77
  : buildSideActions({
69
78
  fullPath,
@@ -73,13 +82,13 @@ export function EntityFormActions({
73
82
  collection,
74
83
  context,
75
84
  sideEntityController,
76
- isSubmitting: formex.isSubmitting,
77
85
  disabled,
78
86
  status,
79
87
  pluginActions,
80
88
  openEntityMode,
81
89
  navigateBack,
82
- formContext
90
+ formContext,
91
+ formex
83
92
  });
84
93
  }
85
94
 
@@ -92,13 +101,13 @@ type ActionsViewProps<M extends object> = {
92
101
  collection: ResolvedEntityCollection,
93
102
  context: FireCMSContext,
94
103
  sideEntityController: SideEntityController,
95
- isSubmitting: boolean,
96
104
  disabled: boolean,
97
105
  status: "new" | "existing" | "copy",
98
106
  pluginActions?: React.ReactNode[],
99
107
  openEntityMode: "side_panel" | "full_screen";
100
108
  navigateBack: () => void;
101
- formContext: FormContext
109
+ formContext: FormContext,
110
+ formex: FormexController<any>;
102
111
  };
103
112
 
104
113
  function buildBottomActions<M extends object>({
@@ -110,15 +119,17 @@ function buildBottomActions<M extends object>({
110
119
  collection,
111
120
  context,
112
121
  sideEntityController,
113
- isSubmitting,
114
122
  disabled,
115
123
  status,
116
124
  pluginActions,
117
125
  openEntityMode,
118
126
  navigateBack,
119
- formContext
127
+ formContext,
128
+ formex
120
129
  }: ActionsViewProps<M>) {
121
130
 
131
+ const hasErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
132
+
122
133
  return <DialogActions position={"absolute"}>
123
134
  {savingError &&
124
135
  <div className="text-right">
@@ -151,7 +162,7 @@ function buildBottomActions<M extends object>({
151
162
  ))}
152
163
  </div>}
153
164
  {pluginActions}
154
- <Button variant="text" disabled={disabled || isSubmitting}
165
+ <Button variant="text" disabled={disabled || formex.isSubmitting}
155
166
  color={"primary"}
156
167
  type="reset">
157
168
  {status === "existing" ? "Discard" : "Clear"}
@@ -159,7 +170,8 @@ function buildBottomActions<M extends object>({
159
170
  <Button variant={"filled"}
160
171
  color="primary"
161
172
  type="submit"
162
- disabled={disabled || isSubmitting}>
173
+ disabled={disabled || formex.isSubmitting}
174
+ startIcon={hasErrors ? <ErrorIcon/> : undefined}>
163
175
  {status === "existing" && "Save"}
164
176
  {status === "copy" && "Create copy"}
165
177
  {status === "new" && "Create"}
@@ -178,12 +190,14 @@ function buildSideActions<M extends object>({
178
190
  collection,
179
191
  context,
180
192
  sideEntityController,
181
- isSubmitting,
182
193
  disabled,
183
194
  status,
184
- pluginActions
195
+ pluginActions,
196
+ formex
185
197
  }: ActionsViewProps<M>) {
186
198
 
199
+ const hasErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
200
+
187
201
  return <div
188
202
  className={cls("overflow-auto h-full flex flex-col gap-2 w-80 2xl:w-96 px-4 py-16 sticky top-0 border-l", defaultBorderMixin)}>
189
203
  <LoadingButton fullWidth={true}
@@ -191,12 +205,13 @@ function buildSideActions<M extends object>({
191
205
  color="primary"
192
206
  type="submit"
193
207
  size={"large"}
194
- disabled={disabled || isSubmitting}>
208
+ startIcon={hasErrors ? <ErrorIcon/> : undefined}
209
+ disabled={disabled || formex.isSubmitting}>
195
210
  {status === "existing" && "Save"}
196
211
  {status === "copy" && "Create copy"}
197
212
  {status === "new" && "Create"}
198
213
  </LoadingButton>
199
- <Button fullWidth={true} variant="text" disabled={disabled || isSubmitting} type="reset">
214
+ <Button fullWidth={true} variant="text" disabled={disabled || formex.isSubmitting} type="reset">
200
215
  {status === "existing" ? "Discard" : "Clear"}
201
216
  </Button>
202
217
 
@@ -1,51 +1,44 @@
1
- import React, { useEffect } from "react";
1
+ import React, { useEffect, useRef } from "react";
2
2
  import { useFormex } from "@firecms/formex";
3
3
 
4
4
  export const ErrorFocus = ({ containerRef }:
5
5
  {
6
6
  containerRef?: React.RefObject<HTMLDivElement>
7
7
  }) => {
8
- const { isSubmitting, isValidating, errors } = useFormex();
8
+ const {
9
+ isValidating,
10
+ errors,
11
+ version
12
+ } = useFormex();
13
+
14
+ const prevVersion = useRef(version);
9
15
 
10
16
  useEffect(() => {
17
+
18
+ if (version === prevVersion.current) {
19
+ return;
20
+ }
21
+
11
22
  const keys = Object.keys(errors);
12
23
 
13
- // Whenever there are errors and the form is submitting but finished validating.
14
- if (keys.length > 0 && isSubmitting && !isValidating) {
24
+ // Whenever there are errors and the form has been submitted and is not validating
25
+ if (!isValidating && keys.length > 0) {
15
26
  const errorElement = containerRef?.current?.querySelector<HTMLDivElement>(
16
27
  `#form_field_${keys[0]}`
17
28
  );
18
29
 
19
- if (errorElement && containerRef?.current) {
20
- const scrollableParent = getScrollableParent(containerRef.current);
21
- if (scrollableParent) {
22
- const top = errorElement.getBoundingClientRect().top;
23
- scrollableParent.scrollTo({
24
- top: scrollableParent.scrollTop + top - 196,
25
- behavior: "smooth"
26
- });
27
- }
30
+ if (errorElement) {
31
+ errorElement.scrollIntoView({
32
+ behavior: "smooth",
33
+ block: "center"
34
+ });
28
35
  const input = errorElement.querySelector("input");
29
36
  if (input) input.focus();
30
37
  }
38
+ prevVersion.current = version;
31
39
  }
32
- }, [isSubmitting, isValidating, errors, containerRef]);
40
+ }, [isValidating, errors, containerRef, version]);
33
41
 
34
42
  // This component does not render anything by itself.
35
43
  return null;
36
44
  };
37
-
38
- const isScrollable = (ele: HTMLElement | null) => {
39
- const hasScrollableContent = ele && ele.scrollHeight > ele.clientHeight;
40
-
41
- const overflowYStyle = ele ? window.getComputedStyle(ele).overflowY : null;
42
- const isOverflowHidden = overflowYStyle && overflowYStyle.indexOf("hidden") !== -1;
43
-
44
- return hasScrollableContent && !isOverflowHidden;
45
- };
46
-
47
- const getScrollableParent = (ele: HTMLElement | null): HTMLElement | null => {
48
- return (!ele || ele === document.body)
49
- ? document.body
50
- : (isScrollable(ele) ? ele : getScrollableParent(ele.parentNode as HTMLElement));
51
- };
@@ -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";