@firecms/core 3.0.0-canary.168 → 3.0.0-canary.169

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 (96) hide show
  1. package/dist/components/EntityCollectionTable/EntityCollectionRowActions.d.ts +2 -1
  2. package/dist/components/EntityCollectionTable/EntityCollectionTable.d.ts +2 -2
  3. package/dist/components/EntityCollectionTable/EntityCollectionTableProps.d.ts +15 -1
  4. package/dist/components/EntityCollectionTable/index.d.ts +1 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +4 -0
  6. package/dist/components/SelectableTable/SelectableTable.d.ts +12 -2
  7. package/dist/components/UnsavedChangesDialog.d.ts +8 -0
  8. package/dist/components/VirtualTable/VirtualTableProps.d.ts +13 -0
  9. package/dist/components/common/index.d.ts +1 -1
  10. package/dist/components/common/{useDataSourceEntityCollectionTableController.d.ts → useDataSourceTableController.d.ts} +10 -2
  11. package/dist/components/common/useDebouncedCallback.d.ts +1 -0
  12. package/dist/components/common/useScrollRestoration.d.ts +14 -0
  13. package/dist/contexts/BreacrumbsContext.d.ts +8 -0
  14. package/dist/core/EntityEditView.d.ts +25 -7
  15. package/dist/core/FireCMSRouter.d.ts +4 -0
  16. package/dist/core/NavigationRoutes.d.ts +0 -1
  17. package/dist/core/index.d.ts +2 -1
  18. package/dist/hooks/data/useEntityFetch.d.ts +2 -1
  19. package/dist/hooks/useBreadcrumbsController.d.ts +26 -0
  20. package/dist/hooks/useBuildNavigationController.d.ts +0 -1
  21. package/dist/index.es.js +2344 -1305
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/index.umd.js +2344 -1306
  24. package/dist/index.umd.js.map +1 -1
  25. package/dist/internal/useUnsavedChangesDialog.d.ts +7 -9
  26. package/dist/routes/CustomCMSRoute.d.ts +4 -0
  27. package/dist/routes/FireCMSRoute.d.ts +1 -0
  28. package/dist/routes/HomePageRoute.d.ts +3 -0
  29. package/dist/types/collections.d.ts +18 -6
  30. package/dist/types/datasource.d.ts +2 -2
  31. package/dist/types/dialogs_controller.d.ts +3 -7
  32. package/dist/types/entities.d.ts +1 -1
  33. package/dist/types/entity_actions.d.ts +1 -6
  34. package/dist/types/navigation.d.ts +28 -12
  35. package/dist/types/plugins.d.ts +1 -0
  36. package/dist/types/side_entity_controller.d.ts +1 -1
  37. package/dist/util/entity_cache.d.ts +23 -0
  38. package/dist/util/navigation_from_path.d.ts +5 -1
  39. package/dist/util/navigation_utils.d.ts +12 -1
  40. package/package.json +5 -5
  41. package/src/components/CircularProgressCenter.tsx +1 -1
  42. package/src/components/DeleteEntityDialog.tsx +1 -1
  43. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +40 -19
  44. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +10 -4
  45. package/src/components/EntityCollectionTable/EntityCollectionTableProps.tsx +18 -1
  46. package/src/components/EntityCollectionTable/index.tsx +1 -1
  47. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +3 -3
  48. package/src/components/EntityCollectionView/EntityCollectionView.tsx +79 -38
  49. package/src/components/NotFoundPage.tsx +2 -2
  50. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +9 -5
  51. package/src/components/SelectableTable/SelectableTable.tsx +18 -3
  52. package/src/components/UnsavedChangesDialog.tsx +42 -0
  53. package/src/components/VirtualTable/VirtualTable.tsx +19 -2
  54. package/src/components/VirtualTable/VirtualTableProps.tsx +15 -0
  55. package/src/components/VirtualTable/fields/VirtualTableInput.tsx +1 -1
  56. package/src/components/common/default_entity_actions.tsx +24 -10
  57. package/src/components/common/index.ts +1 -1
  58. package/src/components/common/{useDataSourceEntityCollectionTableController.tsx → useDataSourceTableController.tsx} +122 -13
  59. package/src/components/common/useDebouncedCallback.tsx +20 -0
  60. package/src/components/common/useScrollRestoration.tsx +68 -0
  61. package/src/contexts/BreacrumbsContext.tsx +38 -0
  62. package/src/contexts/DialogsProvider.tsx +2 -3
  63. package/src/core/DefaultAppBar.tsx +28 -4
  64. package/src/core/EntityEditView.tsx +457 -296
  65. package/src/core/EntitySidePanel.tsx +72 -6
  66. package/src/core/FireCMS.tsx +7 -4
  67. package/src/core/FireCMSRouter.tsx +17 -0
  68. package/src/core/NavigationRoutes.tsx +15 -28
  69. package/src/core/SideDialogs.tsx +10 -5
  70. package/src/core/index.tsx +4 -2
  71. package/src/hooks/data/save.ts +1 -1
  72. package/src/hooks/data/useCollectionFetch.tsx +1 -1
  73. package/src/hooks/data/useEntityFetch.tsx +5 -1
  74. package/src/hooks/useBreadcrumbsController.tsx +31 -0
  75. package/src/hooks/useBuildNavigationController.tsx +59 -21
  76. package/src/hooks/useLargeLayout.tsx +0 -35
  77. package/src/hooks/useResolvedNavigationFrom.tsx +4 -6
  78. package/src/internal/useBuildSideDialogsController.tsx +4 -2
  79. package/src/internal/useBuildSideEntityController.tsx +157 -77
  80. package/src/internal/useUnsavedChangesDialog.tsx +126 -92
  81. package/src/routes/CustomCMSRoute.tsx +21 -0
  82. package/src/routes/FireCMSRoute.tsx +230 -0
  83. package/src/routes/HomePageRoute.tsx +17 -0
  84. package/src/types/collections.ts +19 -6
  85. package/src/types/datasource.ts +2 -1
  86. package/src/types/dialogs_controller.tsx +3 -7
  87. package/src/types/entities.ts +1 -1
  88. package/src/types/entity_actions.tsx +1 -7
  89. package/src/types/navigation.ts +35 -16
  90. package/src/types/plugins.tsx +1 -0
  91. package/src/types/side_entity_controller.tsx +1 -1
  92. package/src/util/entity_cache.ts +196 -0
  93. package/src/util/navigation_from_path.ts +10 -2
  94. package/src/util/navigation_utils.ts +53 -1
  95. package/src/util/parent_references_from_path.ts +3 -3
  96. /package/src/util/{common.tsx → common.ts} +0 -0
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  CMSAnalyticsEvent,
4
4
  Entity,
@@ -7,11 +7,13 @@ import {
7
7
  EntityCustomView,
8
8
  EntityStatus,
9
9
  EntityValues,
10
+ FireCMSContext,
10
11
  FireCMSPlugin,
11
12
  FormContext,
12
13
  PluginFormActionProps,
13
14
  PropertyFieldBindingProps,
14
15
  ResolvedEntityCollection,
16
+ SideEntityController,
15
17
  User
16
18
  } from "../types";
17
19
  import equal from "react-fast-compare"
@@ -23,7 +25,7 @@ import {
23
25
  EntityCollectionView,
24
26
  EntityView,
25
27
  ErrorBoundary,
26
- getFormFieldKeys,
28
+ getFormFieldKeys
27
29
  } from "../components";
28
30
  import {
29
31
  canCreateEntity,
@@ -49,25 +51,29 @@ import {
49
51
  useDataSource,
50
52
  useEntityFetch,
51
53
  useFireCMSContext,
54
+ useLargeLayout,
52
55
  useSideEntityController,
53
56
  useSnackbarController
54
57
  } from "../hooks";
55
58
  import {
56
59
  Alert,
57
60
  Button,
61
+ CheckIcon,
62
+ Chip,
58
63
  CircularProgress,
59
- CloseIcon,
60
64
  cls,
61
65
  defaultBorderMixin,
62
66
  DialogActions,
67
+ EditIcon,
63
68
  IconButton,
69
+ LoadingButton,
64
70
  NotesIcon,
65
71
  paperMixin,
66
72
  Tab,
67
73
  Tabs,
74
+ Tooltip,
68
75
  Typography
69
76
  } from "@firecms/ui";
70
- import { useSideDialogContext } from "./index";
71
77
  import { Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
72
78
  import { useAnalyticsController } from "../hooks/useAnalyticsController";
73
79
  import { CustomIdField } from "../form/components/CustomIdField";
@@ -75,19 +81,40 @@ import { CustomFieldValidator, getYupEntitySchema } from "../form/validation";
75
81
  import { ErrorFocus } from "../form/components/ErrorFocus";
76
82
  import { LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
77
83
  import { ValidationError } from "yup";
84
+ import { getEntityFromCache, removeEntityFromCache, saveEntityToCache } from "../util/entity_cache";
78
85
 
79
86
  const MAIN_TAB_VALUE = "main_##Q$SC^#S6";
80
87
 
88
+ export type OnUpdateParams = {
89
+ entity: Entity<any>,
90
+ status: EntityStatus,
91
+ path: string
92
+ entityId?: string;
93
+ selectedTab?: string;
94
+ collection: EntityCollection<any>
95
+ };
96
+
97
+ export type OnTabChangeParams<M extends Record<string, any>> = {
98
+ path: string;
99
+ entityId?: string;
100
+ selectedTab?: string;
101
+ collection: EntityCollection<M>;
102
+ };
103
+
81
104
  export interface EntityEditViewProps<M extends Record<string, any>> {
82
105
  path: string;
83
106
  collection: EntityCollection<M>;
84
107
  entityId?: string;
108
+ databaseId?: string;
85
109
  copy?: boolean;
86
- selectedSubPath?: string;
110
+ selectedTab?: string;
87
111
  parentCollectionIds: string[];
88
- onValuesAreModified: (modified: boolean) => void;
89
- onUpdate?: (params: { entity: Entity<any> }) => void;
112
+ onValuesModified?: (modified: boolean) => void;
113
+ onSaved?: (params: OnUpdateParams) => void;
90
114
  onClose?: () => void;
115
+ onTabChange?: (props: OnTabChangeParams<M>) => void;
116
+ layout?: "side_panel" | "full_screen";
117
+ barActions?: React.ReactNode;
91
118
  }
92
119
 
93
120
  /**
@@ -109,44 +136,57 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
109
136
  path: props.path,
110
137
  entityId: entityId,
111
138
  collection: props.collection,
139
+ databaseId: props.databaseId,
112
140
  useCache: false
113
141
  });
114
142
 
115
- if (dataLoading) {
143
+ const cachedValues = entityId ? getEntityFromCache(props.path + "/" + entityId) : getEntityFromCache(props.path + "#new");
144
+
145
+ if (dataLoading && !cachedValues) {
116
146
  return <CircularProgressCenter/>
117
147
  }
118
148
 
119
- if (entityId && !entity) {
120
- console.error(`Entity with id ${entityId} not found in collection ${props.collection.path}`);
149
+ if (entityId && !entity && !cachedValues) {
150
+ console.error(`Entity with id ${entityId} not found in collection ${props.path}`);
121
151
  }
122
152
 
123
153
  return <EntityEditViewInner<M> {...props}
124
154
  entityId={entityId}
125
155
  entity={entity}
126
- dataLoading={dataLoading}/>;
156
+ cachedDirtyValues={cachedValues as Partial<M>}
157
+ dataLoading={dataLoading}
158
+ />;
127
159
  }
128
160
 
129
161
  export function EntityEditViewInner<M extends Record<string, any>>({
130
162
  path,
131
163
  entityId: entityIdProp,
132
- selectedSubPath: selectedSubPathProp,
164
+ selectedTab: selectedTabProp,
133
165
  copy,
134
166
  collection,
135
167
  parentCollectionIds,
136
- onValuesAreModified,
137
- onUpdate,
168
+ onValuesModified,
169
+ onSaved,
138
170
  onClose,
171
+ onTabChange,
139
172
  entity,
140
- dataLoading
173
+ cachedDirtyValues,
174
+ dataLoading,
175
+ layout = "side_panel",
176
+ barActions
141
177
  }: EntityEditViewProps<M> & {
142
178
  entity?: Entity<M>,
143
- dataLoading: boolean
179
+ cachedDirtyValues?: Partial<M>, // dirty cached entity in memory
180
+ dataLoading: boolean,
144
181
  }) {
145
182
 
183
+ const largeLayout = useLargeLayout();
146
184
  if (collection.customId && collection.formAutoSave) {
147
185
  console.warn(`The collection ${collection.path} has customId and formAutoSave enabled. This is not supported and formAutoSave will be ignored`);
148
186
  }
149
187
 
188
+ const actionsAtTheBottom = !largeLayout || layout === "side_panel";
189
+
150
190
  const [saving, setSaving] = useState(false);
151
191
  /**
152
192
  * These are the values that are being saved. They are debounced.
@@ -164,15 +204,10 @@ export function EntityEditViewInner<M extends Record<string, any>>({
164
204
  });
165
205
  }, false, 2000);
166
206
 
167
- // const largeLayout = useLargeLayout();
168
- // const largeLayoutTabSelected = useRef(!largeLayout);
169
- // const resolvedFormWidth: string = typeof formWidth === "number" ? `${formWidth}px` : formWidth ?? FORM_CONTAINER_WIDTH;
170
-
171
207
  const inputCollection = collection;
172
208
 
173
209
  const authController = useAuthController();
174
210
  const dataSource = useDataSource(collection);
175
- const sideDialogContext = useSideDialogContext();
176
211
  const sideEntityController = useSideEntityController();
177
212
  const snackbarController = useSnackbarController();
178
213
  const customizationController = useCustomizationController();
@@ -209,30 +244,26 @@ export function EntityEditViewInner<M extends Record<string, any>>({
209
244
 
210
245
  const [entityId, setEntityId] = React.useState<string | undefined>(initialEntityId);
211
246
 
212
- // const doOnValuesChanges = (values?: EntityValues<M>) => {
213
- // const initialValues = formex.initialValues;
214
- // setInternalValues(values);
215
- // if (onValuesChanged)
216
- // onValuesChanged(values);
217
- // if (autoSave && values && !equal(values, initialValues)) {
218
- // save(values);
219
- // }
220
- // };
221
-
222
247
  const [entityIdError, setEntityIdError] = React.useState<boolean>(false);
223
248
  const [savingError, setSavingError] = React.useState<Error | undefined>();
224
249
 
225
250
  const [customIdLoading, setCustomIdLoading] = React.useState<boolean>(false);
226
251
 
227
- const defaultSelectedView = selectedSubPathProp ?? resolveDefaultSelectedView(
252
+ const defaultSelectedView = useMemo(() => resolveDefaultSelectedView(
228
253
  collection ? collection.defaultSelectedView : undefined,
229
254
  {
230
255
  status,
231
256
  entityId
232
257
  }
233
- );
258
+ ), []);
259
+
260
+ const [selectedTab, setSelectedTab] = useState<string>(selectedTabProp ?? defaultSelectedView ?? MAIN_TAB_VALUE);
234
261
 
235
- const [selectedTab, setSelectedTab] = useState<string>(defaultSelectedView ?? MAIN_TAB_VALUE);
262
+ useEffect(() => {
263
+ if ((selectedTabProp ?? MAIN_TAB_VALUE) !== selectedTab) {
264
+ setSelectedTab(selectedTabProp ?? MAIN_TAB_VALUE);
265
+ }
266
+ }, [selectedTabProp]);
236
267
 
237
268
  const mainViewVisible = selectedTab === MAIN_TAB_VALUE;
238
269
 
@@ -247,7 +278,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
247
278
  const [usedEntity, setUsedEntity] = useState<Entity<M> | undefined>(entity);
248
279
  const [readOnly, setReadOnly] = useState<boolean | undefined>(undefined);
249
280
 
250
- const baseDataSourceValuesRef = useRef<Partial<EntityValues<M>>>(getDataSourceEntityValues(initialResolvedCollection, status, usedEntity));
281
+ const baseDataSourceValuesRef = useRef<Partial<EntityValues<M>> | null>(cachedDirtyValues ?? getDataSourceEntityValues(initialResolvedCollection, status, usedEntity));
251
282
 
252
283
  useEffect(() => {
253
284
  if (entity)
@@ -282,8 +313,20 @@ export function EntityEditViewInner<M extends Record<string, any>>({
282
313
  console.error(e);
283
314
  }, [snackbarController]);
284
315
 
316
+ function clearDirtyCache() {
317
+ if (status === "new" || status === "copy") {
318
+ removeEntityFromCache(path + "#new");
319
+ } else {
320
+ removeEntityFromCache(path + "/" + entityId);
321
+ }
322
+ }
323
+
285
324
  const onSaveSuccess = (updatedEntity: Entity<M>, closeAfterSave: boolean) => {
286
325
 
326
+ clearDirtyCache();
327
+
328
+ onValuesModified?.(false);
329
+
287
330
  setSaving(false);
288
331
  if (!autoSave)
289
332
  snackbarController.open({
@@ -293,26 +336,23 @@ export function EntityEditViewInner<M extends Record<string, any>>({
293
336
 
294
337
  setUsedEntity(updatedEntity);
295
338
  setStatus("existing");
339
+ setEntityId(updatedEntity.id);
296
340
 
297
- onValuesAreModified(false);
298
-
299
- if (onUpdate)
300
- onUpdate({ entity: updatedEntity });
301
-
302
- if (closeAfterSave) {
303
- sideDialogContext.setBlocked(false);
304
- sideDialogContext.close(true);
305
- onClose?.();
306
- } else if (status !== "existing") {
307
- sideEntityController.replace({
341
+ if (onSaved) {
342
+ onSaved({
343
+ entity: updatedEntity,
344
+ status,
308
345
  path,
309
346
  entityId: updatedEntity.id,
310
- selectedSubPath: MAIN_TAB_VALUE === selectedTab ? undefined : selectedTab,
311
- updateUrl: true,
312
- collection,
347
+ selectedTab: MAIN_TAB_VALUE === selectedTab ? undefined : selectedTab,
348
+ collection
313
349
  });
314
350
  }
315
351
 
352
+ if (closeAfterSave) {
353
+ onClose?.();
354
+ }
355
+
316
356
  };
317
357
 
318
358
  const onSaveFailure = useCallback((e: Error) => {
@@ -425,6 +465,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
425
465
 
426
466
  const formex: FormexController<M> = useCreateFormex<M>({
427
467
  initialValues: baseDataSourceValuesRef.current as M,
468
+ initialDirty: Boolean(cachedDirtyValues),
428
469
  onSubmit,
429
470
  validation: (values) => {
430
471
  return validationSchema?.validate(values, { abortEarly: false })
@@ -499,8 +540,6 @@ export function EntityEditViewInner<M extends Record<string, any>>({
499
540
  (customView, colIndex) => {
500
541
  if (!customView)
501
542
  return null;
502
- if (selectedTab !== customView.key)
503
- return null;
504
543
  const Builder = customView.Builder;
505
544
  if (!Builder) {
506
545
  console.error("customView.Builder is not defined");
@@ -508,7 +547,10 @@ export function EntityEditViewInner<M extends Record<string, any>>({
508
547
  }
509
548
  return <div
510
549
  className={cls(defaultBorderMixin,
511
- "relative flex-grow w-full h-full overflow-auto ")}
550
+ "relative flex-grow w-full h-full overflow-auto",
551
+ {
552
+ "hidden": selectedTab !== customView.key
553
+ })}
512
554
  key={`custom_view_${customView.key}`}
513
555
  role="tabpanel">
514
556
  <ErrorBoundary>
@@ -526,21 +568,20 @@ export function EntityEditViewInner<M extends Record<string, any>>({
526
568
  const globalLoading = (dataLoading && !usedEntity) ||
527
569
  ((!usedEntity || readOnly === undefined) && (status === "existing" || status === "copy"));
528
570
 
529
- const loading = globalLoading || saving;
530
-
531
571
  const subCollectionsViews = subcollections && subcollections.map(
532
572
  (subcollection, colIndex) => {
533
573
  const subcollectionId = subcollection.id ?? subcollection.path;
534
574
  const fullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
535
- if (selectedTab !== subcollectionId)
536
- return null;
537
575
  return (
538
576
  <div
539
- className={"relative flex-grow h-full overflow-auto w-full"}
577
+ className={cls("relative flex-grow h-full overflow-auto w-full",
578
+ {
579
+ "hidden": selectedTab !== subcollectionId
580
+ })}
540
581
  key={`subcol_${subcollectionId}`}
541
582
  role="tabpanel">
542
583
 
543
- {loading && <CircularProgressCenter/>}
584
+ {globalLoading || saving && <CircularProgressCenter/>}
544
585
 
545
586
  {!globalLoading &&
546
587
  (usedEntity && fullPath
@@ -548,7 +589,9 @@ export function EntityEditViewInner<M extends Record<string, any>>({
548
589
  fullPath={fullPath}
549
590
  parentCollectionIds={[...parentCollectionIds, collection.id]}
550
591
  isSubCollection={true}
551
- {...subcollection}/>
592
+ updateUrl={false}
593
+ {...subcollection}
594
+ openEntityMode={layout}/>
552
595
  : <div
553
596
  className="flex items-center justify-center w-full h-full p-3">
554
597
  <Typography variant={"label"}>
@@ -564,18 +607,17 @@ export function EntityEditViewInner<M extends Record<string, any>>({
564
607
  ).filter(Boolean);
565
608
 
566
609
  const onDiscard = useCallback(() => {
567
- onValuesAreModified(false);
610
+ onValuesModified?.(false);
568
611
  }, []);
569
612
 
570
613
  const onSideTabClick = (value: string) => {
571
614
  setSelectedTab(value);
572
615
  if (status === "existing") {
573
- sideEntityController.replace({
616
+ onTabChange?.({
574
617
  path,
575
618
  entityId,
576
- selectedSubPath: value === MAIN_TAB_VALUE ? undefined : value,
577
- updateUrl: true,
578
- collection,
619
+ selectedTab: value === MAIN_TAB_VALUE ? undefined : value,
620
+ collection
579
621
  });
580
622
  }
581
623
  };
@@ -596,30 +638,6 @@ export function EntityEditViewInner<M extends Record<string, any>>({
596
638
  : undefined);
597
639
  }, []);
598
640
 
599
- // useEffect(() => {
600
- // baseDataSourceValuesRef.current = getDataSourceEntityValues(initialResolvedCollection, status, entity);
601
- // const initialValues = formex.initialValues;
602
- // if (!formex.isSubmitting && initialValues && status === "existing") {
603
- // setUnderlyingChanges(
604
- // Object.entries(resolvedCollection.properties)
605
- // .map(([key, property]) => {
606
- // if (isHidden(property)) {
607
- // return {};
608
- // }
609
- // const initialValue = initialValues[key];
610
- // const latestValue = baseDataSourceValuesRef.current[key];
611
- // if (!equal(initialValue, latestValue)) {
612
- // return { [key]: latestValue };
613
- // }
614
- // return {};
615
- // })
616
- // .reduce((a, b) => ({ ...a, ...b }), {}) as Partial<EntityValues<M>>
617
- // );
618
- // } else {
619
- // setUnderlyingChanges({});
620
- // }
621
- // }, [entity, initialResolvedCollection, status]);
622
-
623
641
  const pluginActions: React.ReactNode[] = [];
624
642
 
625
643
  const plugins = customizationController.plugins;
@@ -632,7 +650,8 @@ export function EntityEditViewInner<M extends Record<string, any>>({
632
650
  collection: inputCollection,
633
651
  context,
634
652
  currentEntityId: entityId,
635
- formContext
653
+ formContext,
654
+ layout
636
655
  };
637
656
  pluginActions.push(...plugins.map((plugin, i) => (
638
657
  plugin.form?.Actions
@@ -707,16 +726,23 @@ export function EntityEditViewInner<M extends Record<string, any>>({
707
726
  return actions;
708
727
  }, [authController, inputCollection, path]);
709
728
 
729
+ const deferredValues = useDeferredValue(formex.values);
710
730
  const modified = formex.dirty;
731
+
732
+ useEffect(() => {
733
+ const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
734
+ if (modified) {
735
+ saveEntityToCache(key, deferredValues);
736
+ }
737
+ }, [deferredValues, modified]);
738
+
711
739
  useEffect(() => {
712
740
  if (!autoSave) {
713
- onValuesAreModified(modified);
714
- } else {
715
- if (formex.values && !equal(formex.values, lastSavedValues.current)) {
716
- save(formex.values);
717
- }
741
+ onValuesModified?.(modified);
718
742
  }
719
- }, [modified, formex.values]);
743
+ }, [modified]);
744
+
745
+ useOnAutoSave(autoSave, formex, lastSavedValues, save);
720
746
 
721
747
  useEffect(() => {
722
748
  if (!autoSave && !formex.isSubmitting && underlyingChanges && entity) {
@@ -820,148 +846,97 @@ export function EntityEditViewInner<M extends Record<string, any>>({
820
846
  });
821
847
  const formActions = entityActions.filter(a => a.includeInForm === undefined || a.includeInForm);
822
848
 
823
- const dialogActions = <DialogActions position={"absolute"}>
824
-
825
- {savingError &&
826
- <div className="text-right">
827
- <Typography color={"error"}>
828
- {savingError.message}
829
- </Typography>
830
- </div>}
831
-
832
- {entity && formActions.length > 0 && <div className="flex-grow flex overflow-auto no-scrollbar">
833
- {formActions.map(action => (
834
- <IconButton
835
- key={action.name}
836
- color="primary"
837
- onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
838
- event.stopPropagation();
839
- if (entity)
840
- action.onClick({
841
- entity,
842
- fullPath: resolvedCollection.path,
843
- collection: resolvedCollection,
844
- context,
845
- sideEntityController,
846
- formContext
847
- });
848
- }}>
849
- {action.icon}
850
- </IconButton>
851
- ))}
852
- </div>}
853
-
854
- {formex.isSubmitting && <CircularProgress size={"small"}/>}
855
-
856
- <Button
857
- variant="text"
858
- disabled={disabled || formex.isSubmitting}
859
- type="reset">
860
- {status === "existing" ? "Discard" : "Clear"}
861
- </Button>
862
-
863
- <Button
864
- variant="text"
865
- color="primary"
866
- type="submit"
867
- disabled={disabled || formex.isSubmitting}
868
- onClick={() => {
869
- closeAfterSaveRef.current = false;
870
- }}>
871
- {status === "existing" && "Save"}
872
- {status === "copy" && "Create copy"}
873
- {status === "new" && "Create"}
874
- </Button>
875
-
876
- <Button
877
- variant="filled"
878
- color="primary"
879
- type="submit"
880
- disabled={disabled || formex.isSubmitting}
881
- onClick={() => {
882
- closeAfterSaveRef.current = true;
883
- }}>
884
- {status === "existing" && "Save and close"}
885
- {status === "copy" && "Create copy and close"}
886
- {status === "new" && "Create and close"}
887
- </Button>
888
-
889
- </DialogActions>;
890
-
891
- function buildForm() {
892
-
893
- let form = <>
894
-
895
- {pluginActions.length > 0 && <div
896
- className={cls("w-full flex justify-end items-center sticky top-0 right-0 left-0 z-10 bg-opacity-60 bg-surface-accent-200 dark:bg-opacity-60 dark:bg-surface-accent-800 backdrop-blur-md")}>
897
- {pluginActions}
898
- </div>}
899
-
900
- <div className="pt-12 pb-16 pl-4 sm:px-8 md:px-10">
901
- <div
902
- className={`w-full py-2 flex flex-col items-start mt-${4 + (pluginActions ? 8 : 0)} lg:mt-${8 + (pluginActions ? 8 : 0)} mb-8`}>
903
-
904
- <Typography
905
- className={"mt-4 flex-grow line-clamp-1 " + inputCollection.hideIdFromForm ? "mb-2" : "mb-0"}
906
- variant={"h4"}>{title ?? inputCollection.singularName ?? inputCollection.name}
907
- </Typography>
908
- <Alert color={"base"} className={"w-full"} size={"small"}>
909
- <code className={"text-xs select-all"}>{path}/{entityId}</code>
910
- </Alert>
911
- </div>
849
+ const dialogActions = actionsAtTheBottom
850
+ ? buildBottomActions({
851
+ savingError: savingError,
852
+ entity: entity,
853
+ formActions: formActions,
854
+ resolvedCollection: resolvedCollection,
855
+ context: context,
856
+ sideEntityController: sideEntityController,
857
+ isSubmitting: formex.isSubmitting,
858
+ disabled: disabled,
859
+ status: status,
860
+ setPendingClose: (value: boolean) => {
861
+ closeAfterSaveRef.current = value;
862
+ },
863
+ pluginActions,
864
+ layout
865
+ })
866
+ : buildSideActions({
867
+ savingError: savingError,
868
+ entity: entity,
869
+ formActions: formActions,
870
+ resolvedCollection: resolvedCollection,
871
+ context: context,
872
+ sideEntityController: sideEntityController,
873
+ isSubmitting: formex.isSubmitting,
874
+ disabled: disabled,
875
+ status: status,
876
+ pluginActions,
877
+ layout
878
+ });
912
879
 
913
- {!collection.hideIdFromForm &&
914
- <CustomIdField customId={inputCollection.customId}
915
- entityId={entityId}
916
- status={status}
917
- onChange={setEntityId}
918
- error={entityIdError}
919
- loading={customIdLoading}
920
- entity={entity}/>}
880
+ const entityView = (readOnly === undefined)
881
+ ? <></>
882
+ : (!readOnly
883
+ ? (
884
+ <ErrorBoundary>
885
+ <div className="flex flex-col w-full pt-12 pb-16 px-4 sm:px-8 md:px-10">
886
+
887
+ {formex.dirty
888
+ ? <Tooltip title={"Unsaved changes"}
889
+ className={"self-end sticky top-4 z-10"}>
890
+ <Chip size={"small"} colorScheme={"orangeDarker"}>
891
+ <EditIcon size={"smallest"}/>
892
+ </Chip>
893
+ </Tooltip>
894
+ : <Tooltip title={"In sync with the database"}
895
+ className={"self-end sticky top-4 z-10"}>
896
+ <Chip size={"small"}>
897
+ <CheckIcon size={"smallest"}/>
898
+ </Chip>
899
+ </Tooltip>}
900
+
901
+ <div
902
+ className={"w-full py-2 flex flex-col items-start mt-4 lg:mt-8 mb-8"}>
903
+
904
+ <Typography
905
+ className={"mt-4 flex-grow line-clamp-1 " + inputCollection.hideIdFromForm ? "mb-2" : "mb-0"}
906
+ variant={"h4"}>{title ?? inputCollection.singularName ?? inputCollection.name}
907
+ </Typography>
908
+ <Alert color={"base"} className={"w-full"} size={"small"}>
909
+ <code
910
+ className={"text-xs select-all text-text-secondary dark:text-text-secondary-dark"}>{path}/{entityId}</code>
911
+ </Alert>
912
+ </div>
913
+
914
+ {!collection.hideIdFromForm &&
915
+ <CustomIdField customId={inputCollection.customId}
916
+ entityId={entityId}
917
+ status={status}
918
+ onChange={setEntityId}
919
+ error={entityIdError}
920
+ loading={customIdLoading}
921
+ entity={entity}/>}
922
+
923
+ {entityId && formContext && <>
924
+ <div className="mt-12 flex flex-col gap-8"
925
+ ref={formRef}>
926
+
927
+ {formFields}
928
+
929
+ <ErrorFocus containerRef={formRef}/>
921
930
 
922
- {entityId && formContext && <>
923
- <div className="mt-12 flex flex-col gap-8"
924
- ref={formRef}>
931
+ </div>
925
932
 
926
- {formFields}
933
+ {actionsAtTheBottom && <div className="h-16"/>}
927
934
 
928
- <ErrorFocus containerRef={formRef}/>
935
+ </>}
929
936
 
930
937
  </div>
931
-
932
- <div className="h-14"/>
933
-
934
- </>}
935
-
936
- </div>
937
- </>;
938
-
939
- if (plugins) {
940
- plugins.forEach((plugin: FireCMSPlugin) => {
941
- if (plugin.form?.provider) {
942
- form = (
943
- <plugin.form.provider.Component
944
- status={status}
945
- path={path}
946
- collection={collection}
947
- onDiscard={onDiscard}
948
- entity={usedEntity}
949
- context={context}
950
- formContext={formContext}
951
- {...plugin.form.provider.props}>
952
- {form}
953
- </plugin.form.provider.Component>
954
- );
955
- }
956
- });
957
- }
958
- return <ErrorBoundary>{form}</ErrorBoundary>;
959
- }
960
-
961
- const entityView = (readOnly === undefined)
962
- ? <></>
963
- : (!readOnly
964
- ? buildForm()
938
+ </ErrorBoundary>
939
+ )
965
940
  : (
966
941
  <>
967
942
  <Typography
@@ -980,7 +955,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
980
955
  const subcollectionTabs = subcollections && subcollections.map(
981
956
  (subcollection) =>
982
957
  <Tab
983
- className="text-sm min-w-[140px]"
958
+ className="text-sm min-w-[120px]"
984
959
  value={subcollection.id}
985
960
  key={`entity_detail_collection_tab_${subcollection.name}`}>
986
961
  {subcollection.name}
@@ -991,7 +966,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
991
966
  (view) =>
992
967
 
993
968
  <Tab
994
- className="text-sm min-w-[140px]"
969
+ className="text-sm min-w-[120px]"
995
970
  value={view.key}
996
971
  key={`entity_detail_collection_tab_${view.name}`}>
997
972
  {view.name}
@@ -1003,93 +978,115 @@ export function EntityEditViewInner<M extends Record<string, any>>({
1003
978
  onIdChange(entityId);
1004
979
  }, [entityId, onIdChange]);
1005
980
 
1006
- return (
1007
- <Formex value={formex}>
981
+ const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews;
1008
982
 
1009
- <div className="flex flex-col h-full w-full">
983
+ let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-900">
1010
984
 
1011
- <div
1012
- className={cls(defaultBorderMixin, "no-scrollbar h-16 border-b pl-2 pr-2 pt-1 flex items-end overflow-scroll bg-surface-50 dark:bg-surface-950")}>
1013
-
1014
- <div
1015
- className="pb-1 self-center">
1016
- <IconButton
1017
- onClick={() => {
1018
- onClose?.();
1019
- return sideDialogContext.close(false);
1020
- }}>
1021
- <CloseIcon size={"small"}/>
1022
- </IconButton>
1023
- </div>
985
+ {shouldShowTopBar && <div
986
+ className={cls("h-14 flex overflow-visible overflow-x-scroll w-full no-scrollbar h-14 border-b pl-2 pr-2 pt-1 flex items-end bg-surface-50 dark:bg-surface-900", defaultBorderMixin)}>
987
+
988
+ {barActions}
1024
989
 
1025
- <div className={"flex-grow"}/>
990
+ <div className={"flex-grow"}/>
1026
991
 
1027
- {globalLoading && <div
1028
- className="self-center">
1029
- <CircularProgress size={"small"}/>
1030
- </div>}
992
+ {globalLoading && <div
993
+ className="self-center">
994
+ <CircularProgress size={"small"}/>
995
+ </div>}
1031
996
 
1032
- <Tabs
1033
- value={selectedTab}
1034
- onValueChange={(value) => {
1035
- onSideTabClick(value);
1036
- }}
1037
- innerClassName="pl-4 pr-4 pt-0">
997
+ {hasAdditionalViews && <Tabs
998
+ value={selectedTab}
999
+ onValueChange={(value) => {
1000
+ onSideTabClick(value);
1001
+ }}>
1038
1002
 
1039
- <Tab
1040
- disabled={!hasAdditionalViews}
1041
- value={MAIN_TAB_VALUE}
1042
- className={`${
1043
- !hasAdditionalViews ? "hidden" : ""
1044
- } text-sm min-w-[140px]`}
1045
- >{collection.singularName ?? collection.name}</Tab>
1003
+ <Tab
1004
+ disabled={!hasAdditionalViews}
1005
+ value={MAIN_TAB_VALUE}
1006
+ className={"text-sm min-w-[120px]"}
1007
+ >{collection.singularName ?? collection.name}</Tab>
1046
1008
 
1047
- {customViewTabs}
1009
+ {customViewTabs}
1048
1010
 
1049
- {subcollectionTabs}
1050
- </Tabs>
1011
+ {subcollectionTabs}
1012
+ </Tabs>}
1051
1013
 
1052
- </div>
1014
+ </div>}
1053
1015
 
1054
- <form
1055
- onSubmit={formex.handleSubmit}
1056
- onReset={() => {
1057
- formex.resetForm();
1058
- return onDiscard && onDiscard();
1059
- }}
1060
- noValidate
1061
- className={"flex-grow h-full flex overflow-auto flex-col w-full"}>
1062
-
1063
- <div
1064
- role="tabpanel"
1065
- hidden={!mainViewVisible}
1066
- id={`form_${path}`}
1067
- className={"relative w-full"}>
1068
-
1069
- {globalLoading
1070
- ? <CircularProgressCenter/>
1071
- : entityView}
1016
+ <form
1017
+ onSubmit={formex.handleSubmit}
1018
+ onReset={() => {
1072
1019
 
1073
- </div>
1020
+ clearDirtyCache();
1074
1021
 
1075
- {customViewsView}
1022
+ formex.resetForm({
1023
+ values: getDataSourceEntityValues(initialResolvedCollection, status, entity) as M,
1024
+ });
1076
1025
 
1077
- {subCollectionsViews}
1026
+ return onDiscard();
1027
+ }}
1028
+ noValidate
1029
+ className={"flex-1 flex flex-row w-full overflow-y-auto justify-center"}>
1078
1030
 
1079
- {shouldShowEntityActions && dialogActions}
1031
+ <div
1032
+ role="tabpanel"
1033
+ hidden={!mainViewVisible}
1034
+ id={`form_${path}`}
1035
+ className={cls("relative flex flex-row max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl w-full h-fit", {
1036
+ "hidden": !mainViewVisible
1037
+ })}>
1080
1038
 
1081
- </form>
1039
+ {globalLoading
1040
+ ? <div className="w-full pt-12 pb-16 px-4 sm:px-8 md:px-10"><CircularProgressCenter/></div>
1041
+ : entityView}
1082
1042
 
1083
1043
  </div>
1044
+
1045
+ {mainViewVisible && shouldShowEntityActions && !actionsAtTheBottom && dialogActions}
1046
+
1047
+ {customViewsView}
1048
+
1049
+ {subCollectionsViews}
1050
+
1051
+ {shouldShowEntityActions && actionsAtTheBottom && dialogActions}
1052
+
1053
+ </form>
1054
+
1055
+ </div>;
1056
+
1057
+ if (plugins) {
1058
+ plugins.forEach((plugin: FireCMSPlugin) => {
1059
+ if (plugin.form?.provider) {
1060
+ result = (
1061
+ <plugin.form.provider.Component
1062
+ status={status}
1063
+ path={path}
1064
+ collection={collection}
1065
+ onDiscard={onDiscard}
1066
+ entity={usedEntity}
1067
+ context={context}
1068
+ formContext={formContext}
1069
+ {...plugin.form.provider.props}>
1070
+ {result}
1071
+ </plugin.form.provider.Component>
1072
+ );
1073
+ }
1074
+ });
1075
+ }
1076
+
1077
+ return (
1078
+ <Formex value={formex}>
1079
+
1080
+ {result}
1084
1081
  </Formex>
1085
1082
  );
1086
1083
  }
1087
1084
 
1088
- function getDataSourceEntityValues<M extends object>(initialResolvedCollection: ResolvedEntityCollection,
1085
+ function getDataSourceEntityValues<M extends object>(collection: ResolvedEntityCollection,
1089
1086
  status: "new" | "existing" | "copy",
1090
1087
  entity: Entity<M> | undefined): Partial<EntityValues<M>> {
1091
1088
 
1092
- const properties = initialResolvedCollection.properties;
1089
+ const properties = collection.properties;
1093
1090
  if ((status === "existing" || status === "copy") && entity) {
1094
1091
  return entity.values ?? getDefaultValuesFor(properties);
1095
1092
  } else if (status === "new") {
@@ -1127,3 +1124,167 @@ export function yupToFormErrors(yupError: ValidationError): Record<string, any>
1127
1124
  }
1128
1125
  return errors;
1129
1126
  }
1127
+
1128
+ type ActionsViewProps<M extends object> = {
1129
+ savingError: Error | undefined,
1130
+ entity: Entity<M> | undefined,
1131
+ formActions: EntityAction[],
1132
+ resolvedCollection: ResolvedEntityCollection,
1133
+ context: FireCMSContext,
1134
+ sideEntityController: SideEntityController,
1135
+ isSubmitting: boolean,
1136
+ disabled: boolean,
1137
+ status: "new" | "existing" | "copy",
1138
+ setPendingClose?: (value: boolean) => void,
1139
+ pluginActions?: React.ReactNode[],
1140
+ layout: "side_panel" | "full_screen";
1141
+ };
1142
+
1143
+ function buildBottomActions<M extends object>({
1144
+ savingError,
1145
+ entity,
1146
+ formActions,
1147
+ resolvedCollection,
1148
+ context,
1149
+ sideEntityController,
1150
+ isSubmitting,
1151
+ disabled,
1152
+ status,
1153
+ setPendingClose,
1154
+ pluginActions,
1155
+ layout
1156
+ }: ActionsViewProps<M>) {
1157
+
1158
+ return <DialogActions position={"absolute"}>
1159
+
1160
+ {savingError &&
1161
+ <div className="text-right">
1162
+ <Typography color={"error"}>
1163
+ {savingError.message}
1164
+ </Typography>
1165
+ </div>}
1166
+
1167
+ {entity && formActions.length > 0 && <div className="flex-grow flex overflow-auto no-scrollbar">
1168
+ {formActions.map(action => (
1169
+ <IconButton
1170
+ key={action.name}
1171
+ color="primary"
1172
+ onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
1173
+ event.stopPropagation();
1174
+ if (entity)
1175
+ action.onClick({
1176
+ entity,
1177
+ fullPath: resolvedCollection.path,
1178
+ collection: resolvedCollection,
1179
+ context,
1180
+ sideEntityController,
1181
+ openEntityMode: layout
1182
+ });
1183
+ }}>
1184
+ {action.icon}
1185
+ </IconButton>
1186
+ ))}
1187
+ </div>}
1188
+
1189
+ {pluginActions}
1190
+
1191
+ <Button
1192
+ variant="text"
1193
+ disabled={disabled || isSubmitting}
1194
+ type="reset">
1195
+ {status === "existing" ? "Discard" : "Clear"}
1196
+ </Button>
1197
+
1198
+ <Button
1199
+ variant="text"
1200
+ color="primary"
1201
+ type="submit"
1202
+ disabled={disabled || isSubmitting}
1203
+ onClick={() => {
1204
+ setPendingClose?.(false);
1205
+ }}>
1206
+ {status === "existing" && "Save"}
1207
+ {status === "copy" && "Create copy"}
1208
+ {status === "new" && "Create"}
1209
+ </Button>
1210
+
1211
+ <LoadingButton
1212
+ variant="filled"
1213
+ color="primary"
1214
+ type="submit"
1215
+ loading={isSubmitting}
1216
+ disabled={disabled}
1217
+ onClick={() => {
1218
+ setPendingClose?.(true);
1219
+ }}>
1220
+ {status === "existing" && "Save and close"}
1221
+ {status === "copy" && "Create copy and close"}
1222
+ {status === "new" && "Create and close"}
1223
+ </LoadingButton>
1224
+
1225
+ </DialogActions>;
1226
+ }
1227
+
1228
+ function buildSideActions<M extends object>({
1229
+ savingError,
1230
+ entity,
1231
+ formActions,
1232
+ resolvedCollection,
1233
+ context,
1234
+ sideEntityController,
1235
+ isSubmitting,
1236
+ disabled,
1237
+ status,
1238
+ setPendingClose,
1239
+ pluginActions
1240
+ }: ActionsViewProps<M>) {
1241
+
1242
+ return <div
1243
+ className={cls("overflow-auto h-full flex flex-col gap-2 w-96 px-4 py-16 sticky top-0 border-l", defaultBorderMixin)}>
1244
+
1245
+ <LoadingButton
1246
+ fullWidth={true}
1247
+ variant="filled"
1248
+ color="primary"
1249
+ type="submit"
1250
+ size={"large"}
1251
+ disabled={disabled || isSubmitting}
1252
+ onClick={() => {
1253
+ setPendingClose?.(false);
1254
+ }}>
1255
+ {status === "existing" && "Save"}
1256
+ {status === "copy" && "Create copy"}
1257
+ {status === "new" && "Create"}
1258
+ </LoadingButton>
1259
+
1260
+ <Button
1261
+ fullWidth={true}
1262
+ variant="text"
1263
+ disabled={disabled || isSubmitting}
1264
+ type="reset">
1265
+ {status === "existing" ? "Discard" : "Clear"}
1266
+ </Button>
1267
+
1268
+ {pluginActions}
1269
+
1270
+ {savingError &&
1271
+ <div className="text-right">
1272
+ <Typography color={"error"}>
1273
+ {savingError.message}
1274
+ </Typography>
1275
+ </div>}
1276
+
1277
+ </div>;
1278
+ }
1279
+
1280
+ function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<any>, lastSavedValues: any, save: (values: EntityValues<any>) => Promise<void>) {
1281
+ if (!autoSave) return;
1282
+ useEffect(() => {
1283
+ if (autoSave) {
1284
+ if (formex.values && !equal(formex.values, lastSavedValues.current)) {
1285
+ save(formex.values);
1286
+ }
1287
+ }
1288
+ }, [formex.values]);
1289
+ }
1290
+