@firecms/core 3.0.0-canary.186 → 3.0.0-canary.187

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.
@@ -0,0 +1,952 @@
1
+ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ CMSAnalyticsEvent,
4
+ Entity,
5
+ EntityAction,
6
+ EntityCollection,
7
+ EntityStatus,
8
+ EntityValues,
9
+ FireCMSContext,
10
+ FormContext,
11
+ PluginFormActionProps,
12
+ PropertyFieldBindingProps,
13
+ ResolvedEntityCollection,
14
+ SideEntityController
15
+ } from "../types";
16
+ import equal from "react-fast-compare";
17
+
18
+ import { copyEntityAction, deleteEntityAction, ErrorBoundary, getFormFieldKeys } from "../components";
19
+ import {
20
+ canCreateEntity,
21
+ canDeleteEntity,
22
+ getDefaultValuesFor,
23
+ getEntityTitlePropertyKey,
24
+ getValueInPath,
25
+ isHidden,
26
+ isReadOnly,
27
+ mergeEntityActions,
28
+ resolveCollection,
29
+ useDebouncedCallback
30
+ } from "../util";
31
+
32
+ import {
33
+ saveEntityWithCallbacks,
34
+ useAuthController,
35
+ useCustomizationController,
36
+ useDataSource,
37
+ useFireCMSContext,
38
+ useSideEntityController,
39
+ useSnackbarController
40
+ } from "../hooks";
41
+ import {
42
+ Alert,
43
+ Button,
44
+ CheckIcon,
45
+ Chip,
46
+ cls,
47
+ defaultBorderMixin,
48
+ DialogActions,
49
+ EditIcon,
50
+ IconButton,
51
+ LoadingButton,
52
+ NotesIcon,
53
+ paperMixin,
54
+ Tooltip,
55
+ Typography
56
+ } from "@firecms/ui";
57
+ import { Formex, FormexController, getIn, setIn, useCreateFormex } from "@firecms/formex";
58
+ import { useAnalyticsController } from "../hooks/useAnalyticsController";
59
+ import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
60
+ import { ValidationError } from "yup";
61
+ import { removeEntityFromCache, saveEntityToCache } from "../util/entity_cache";
62
+ import { CustomIdField } from "../form/components/CustomIdField";
63
+ import { ErrorFocus } from "../form/components/ErrorFocus";
64
+ import { CustomFieldValidator, getYupEntitySchema } from "../form/validation";
65
+
66
+ export type OnUpdateParams = {
67
+ entity: Entity<any>,
68
+ status: EntityStatus,
69
+ path: string,
70
+ entityId?: string;
71
+ selectedTab?: string;
72
+ collection: EntityCollection<any>
73
+ };
74
+
75
+ type EntityFormProps<M extends Record<string, any>> = {
76
+ path: string;
77
+ collection: EntityCollection<M>;
78
+ entityId?: string;
79
+ entity?: Entity<M>;
80
+ databaseId?: string;
81
+ onIdChange?: (id: string) => void;
82
+ onValuesModified?: (modified: boolean) => void;
83
+ onSaved?: (params: OnUpdateParams) => void;
84
+ onClose?: () => void;
85
+ cachedDirtyValues?: Partial<M>; // dirty cached entity in memory
86
+ onFormContextReady?: (formContext: FormContext) => void;
87
+ forceActionsAtTheBottom?: boolean;
88
+ initialStatus: EntityStatus;
89
+ className?: string;
90
+ onStatusChange?: (status: EntityStatus) => void;
91
+ onEntityChange?: (entity: Entity<M>) => void;
92
+ formex?: FormexController<M>;
93
+ openEntityMode?: "side_panel" | "full_screen";
94
+ };
95
+
96
+ export function EntityForm<M extends Record<string, any>>({
97
+ path,
98
+ entityId: entityIdProp,
99
+ collection,
100
+ onValuesModified,
101
+ onIdChange,
102
+ onSaved,
103
+ onClose,
104
+ entity,
105
+ cachedDirtyValues,
106
+ onFormContextReady,
107
+ forceActionsAtTheBottom,
108
+ initialStatus,
109
+ className,
110
+ onStatusChange,
111
+ onEntityChange,
112
+ openEntityMode = "full_screen",
113
+ formex: formexProp
114
+ }: EntityFormProps<M>) {
115
+
116
+ if (collection.customId && collection.formAutoSave) {
117
+ console.warn(`The collection ${collection.path} has customId and formAutoSave enabled. This is not supported and formAutoSave will be ignored`);
118
+ }
119
+
120
+ const [status, setStatus] = useState<EntityStatus>(initialStatus);
121
+ const [saving, setSaving] = useState(false);
122
+
123
+ const updateStatus = (status: EntityStatus) => {
124
+ setStatus(status);
125
+ onStatusChange?.(status);
126
+ };
127
+
128
+ const [valuesToBeSaved, setValuesToBeSaved] = useState<EntityValues<M> | undefined>(undefined);
129
+ useDebouncedCallback(valuesToBeSaved, () => {
130
+ if (valuesToBeSaved)
131
+ saveEntity({
132
+ entityId: entityIdProp,
133
+ collection,
134
+ path,
135
+ values: valuesToBeSaved,
136
+ closeAfterSave: false
137
+ });
138
+ }, false, 2000);
139
+
140
+ const authController = useAuthController();
141
+ const dataSource = useDataSource(collection);
142
+ const sideEntityController = useSideEntityController();
143
+ const snackbarController = useSnackbarController();
144
+ const customizationController = useCustomizationController();
145
+ const context = useFireCMSContext();
146
+ const closeAfterSaveRef = useRef(false);
147
+ const analyticsController = useAnalyticsController();
148
+
149
+ const [underlyingChanges, setUnderlyingChanges] = useState<Partial<EntityValues<M>>>({});
150
+
151
+ const [customIdLoading, setCustomIdLoading] = useState<boolean>(false);
152
+
153
+ const mustSetCustomId: boolean = (status === "new" || status === "copy") &&
154
+ (Boolean(collection.customId) && collection.customId !== "optional");
155
+
156
+ const initialEntityId: string | undefined = useMemo((): string | undefined => {
157
+ if (status === "new" || status === "copy") {
158
+ if (mustSetCustomId) {
159
+ return undefined;
160
+ } else {
161
+ return dataSource.generateEntityId(path, collection);
162
+ }
163
+ } else {
164
+ return entityIdProp;
165
+ }
166
+ }, [entityIdProp, status]);
167
+
168
+ const [entityId, setEntityId] = useState<string | undefined>(initialEntityId);
169
+ const [entityIdError, setEntityIdError] = useState<boolean>(false);
170
+ const [savingError, setSavingError] = useState<Error | undefined>();
171
+
172
+ const autoSave = collection.formAutoSave && !collection.customId;
173
+
174
+ const onSubmit = (values: EntityValues<M>, formexController: FormexController<EntityValues<M>>) => {
175
+
176
+ if (mustSetCustomId && !entityId) {
177
+ console.error("Missing custom Id");
178
+ setEntityIdError(true);
179
+ formexController.setSubmitting(false);
180
+ return;
181
+ }
182
+
183
+ setSavingError(undefined);
184
+ setEntityIdError(false);
185
+
186
+ if (status === "existing") {
187
+ if (!entity?.id) throw Error("Form misconfiguration when saving, no id for existing entity");
188
+ } else if (status === "new" || status === "copy") {
189
+ if (collection.customId) {
190
+ if (collection.customId !== "optional" && !entityId) {
191
+ throw Error("Form misconfiguration when saving, entityId should be set");
192
+ }
193
+ }
194
+ } else {
195
+ throw Error("New FormType added, check EntityForm");
196
+ }
197
+
198
+ return save(values)
199
+ ?.then(_ => {
200
+ formexController.resetForm({
201
+ values,
202
+ submitCount: 0,
203
+ touched: {}
204
+ });
205
+ })
206
+ .finally(() => {
207
+ formexController.setSubmitting(false);
208
+ });
209
+ };
210
+
211
+ const formex: FormexController<M> = formexProp ?? useCreateFormex<M>({
212
+ initialValues: (cachedDirtyValues ?? getInitialEntityValues(collection, path, status, entity)) as M,
213
+ initialDirty: Boolean(cachedDirtyValues),
214
+ onSubmit,
215
+ onReset: () => {
216
+ clearDirtyCache();
217
+ onValuesModified?.(false);
218
+ },
219
+ validation: (values) => {
220
+ return validationSchema?.validate(values, { abortEarly: false })
221
+ .then(() => {
222
+ return {};
223
+ })
224
+ .catch((e: any) => {
225
+ return yupToFormErrors(e);
226
+ });
227
+ }
228
+ });
229
+
230
+ const resolvedCollection = useMemo(() => resolveCollection<M>({
231
+ collection,
232
+ path,
233
+ entityId,
234
+ values: formex.values,
235
+ previousValues: formex.initialValues,
236
+ propertyConfigs: customizationController.propertyConfigs
237
+ }), [collection, path, entityId, formex.values, formex.initialValues, customizationController.propertyConfigs]);
238
+
239
+ const onPreSaveHookError = useCallback((e: Error) => {
240
+ setSaving(false);
241
+ snackbarController.open({
242
+ type: "error",
243
+ message: "Error before saving: " + e?.message
244
+ });
245
+ console.error(e);
246
+ }, [snackbarController]);
247
+
248
+ const onSaveSuccessHookError = useCallback((e: Error) => {
249
+ setSaving(false);
250
+ snackbarController.open({
251
+ type: "error",
252
+ message: "Error after saving (entity is saved): " + e?.message
253
+ });
254
+ console.error(e);
255
+ }, [snackbarController]);
256
+
257
+ function clearDirtyCache() {
258
+ if (status === "new" || status === "copy") {
259
+ removeEntityFromCache(path + "#new");
260
+ } else {
261
+ removeEntityFromCache(path + "/" + entityId);
262
+ }
263
+ }
264
+
265
+ const onSaveSuccess = (updatedEntity: Entity<M>, closeAfterSave: boolean) => {
266
+
267
+ clearDirtyCache();
268
+ onValuesModified?.(false);
269
+ setSaving(false);
270
+ if (!autoSave)
271
+ snackbarController.open({
272
+ type: "success",
273
+ message: `${collection.singularName ?? collection.name}: Saved correctly`
274
+ });
275
+ onEntityChange?.(updatedEntity);
276
+ updateStatus("existing");
277
+ setEntityId(updatedEntity.id);
278
+
279
+ if (onSaved) {
280
+ onSaved({
281
+ entity: updatedEntity,
282
+ status,
283
+ path,
284
+ entityId: updatedEntity.id,
285
+ collection
286
+ });
287
+ }
288
+
289
+ if (closeAfterSave) {
290
+ onClose?.();
291
+ }
292
+ };
293
+
294
+ const onSaveFailure = useCallback((e: Error) => {
295
+ setSaving(false);
296
+ snackbarController.open({
297
+ type: "error",
298
+ message: "Error saving: " + e?.message
299
+ });
300
+ console.error("Error saving entity", path, entityId);
301
+ console.error(e);
302
+ }, [entityId, path, snackbarController]);
303
+
304
+ const saveEntity = ({
305
+ values,
306
+ previousValues,
307
+ closeAfterSave,
308
+ entityId,
309
+ collection,
310
+ path
311
+ }: {
312
+ collection: EntityCollection<M>,
313
+ path: string,
314
+ entityId: string | undefined,
315
+ values: M,
316
+ previousValues?: M,
317
+ closeAfterSave: boolean,
318
+ }) => {
319
+ setSaving(true);
320
+ return saveEntityWithCallbacks({
321
+ path,
322
+ entityId,
323
+ values,
324
+ previousValues,
325
+ collection,
326
+ status,
327
+ dataSource,
328
+ context,
329
+ onSaveSuccess: (updatedEntity: Entity<M>) => onSaveSuccess(updatedEntity, closeAfterSave),
330
+ onSaveFailure,
331
+ onPreSaveHookError,
332
+ onSaveSuccessHookError
333
+ }).then();
334
+ };
335
+
336
+ type EntityFormSaveParams<M extends Record<string, any>> = {
337
+ collection: ResolvedEntityCollection<M>,
338
+ path: string,
339
+ entityId: string | undefined,
340
+ values: EntityValues<M>,
341
+ previousValues?: EntityValues<M>,
342
+ closeAfterSave: boolean,
343
+ autoSave: boolean
344
+ };
345
+
346
+ const onSaveEntityRequest = async ({
347
+ collection,
348
+ path,
349
+ entityId,
350
+ values,
351
+ previousValues,
352
+ closeAfterSave,
353
+ autoSave
354
+ }: EntityFormSaveParams<M>): Promise<void> => {
355
+ if (!status)
356
+ return;
357
+ if (autoSave) {
358
+ setValuesToBeSaved(values);
359
+ } else {
360
+ return saveEntity({
361
+ collection,
362
+ path,
363
+ entityId,
364
+ values,
365
+ previousValues,
366
+ closeAfterSave
367
+ });
368
+ }
369
+ };
370
+
371
+ const lastSavedValues = useRef<EntityValues<M> | undefined>(entity?.values);
372
+ const save = (values: EntityValues<M>): Promise<void> => {
373
+ lastSavedValues.current = values;
374
+ return onSaveEntityRequest({
375
+ collection: resolvedCollection,
376
+ path,
377
+ entityId,
378
+ values,
379
+ previousValues: entity?.values,
380
+ closeAfterSave: closeAfterSaveRef.current,
381
+ autoSave: autoSave ?? false
382
+ }).then(_ => {
383
+ const eventName: CMSAnalyticsEvent = status === "new"
384
+ ? "new_entity_saved"
385
+ : (status === "copy" ? "entity_copied" : (status === "existing" ? "entity_edited" : "unmapped_event"));
386
+ analyticsController.onAnalyticsEvent?.(eventName, { path });
387
+ }).catch(e => {
388
+ console.error(e);
389
+ setSavingError(e);
390
+ }).finally(() => {
391
+ closeAfterSaveRef.current = false;
392
+ });
393
+ };
394
+
395
+ const formContext: FormContext<M> = {
396
+ // @ts-ignore
397
+ setFieldValue: useCallback(formex.setFieldValue, []),
398
+ values: formex.values,
399
+ collection: resolvedCollection,
400
+ entityId: entityId as string,
401
+ path,
402
+ save,
403
+ formex,
404
+ entity,
405
+ savingError,
406
+ status,
407
+ openEntityMode,
408
+ setPendingClose: (value: boolean) => {
409
+ closeAfterSaveRef.current = value;
410
+ }
411
+ };
412
+
413
+ useEffect(() => {
414
+ onFormContextReady?.(formContext);
415
+ }, [formex.version, resolvedCollection, entityId, path]);
416
+
417
+ const onIdUpdateError = useCallback((error: any) => {
418
+ snackbarController.open({
419
+ type: "error",
420
+ message: "Error updating id, check the console"
421
+ });
422
+ }, []);
423
+
424
+ const pluginActions: React.ReactNode[] = [];
425
+ const plugins = customizationController.plugins;
426
+
427
+ if (plugins && collection) {
428
+ const actionProps: PluginFormActionProps = {
429
+ entityId,
430
+ path,
431
+ status,
432
+ collection: collection,
433
+ context,
434
+ currentEntityId: entityId,
435
+ formContext,
436
+ openEntityMode
437
+ };
438
+ pluginActions.push(...plugins.map((plugin) => (
439
+ plugin.form?.Actions
440
+ ? <plugin.form.Actions
441
+ key={`actions_${plugin.key}`} {...actionProps} />
442
+ : null
443
+ )).filter(Boolean));
444
+ }
445
+
446
+ const titlePropertyKey = getEntityTitlePropertyKey(resolvedCollection, customizationController.propertyConfigs);
447
+ const title = (formex.values && titlePropertyKey ? getValueInPath(formex.values, titlePropertyKey) : undefined)
448
+ ?? collection.singularName
449
+ ?? collection.name;
450
+
451
+ const onIdUpdate = collection.callbacks?.onIdUpdate;
452
+ const doOnIdUpdate = useCallback(async () => {
453
+ if (onIdUpdate && formex.values && (status === "new" || status === "copy")) {
454
+ setCustomIdLoading(true);
455
+ try {
456
+ const updatedId = await onIdUpdate({
457
+ collection: resolvedCollection,
458
+ path,
459
+ entityId,
460
+ values: formex.values,
461
+ context
462
+ });
463
+ setEntityId(updatedId);
464
+ } catch (e) {
465
+ onIdUpdateError?.(e);
466
+ console.error(e);
467
+ }
468
+ setCustomIdLoading(false);
469
+ }
470
+ }, [entityId, formex.values, status, onIdUpdate, resolvedCollection, path, context, onIdUpdateError]);
471
+
472
+ useEffect(() => {
473
+ doOnIdUpdate();
474
+ }, [doOnIdUpdate]);
475
+
476
+ useEffect(() => {
477
+ if (!autoSave) {
478
+ onValuesModified?.(modified);
479
+ }
480
+ }, [formex.dirty]);
481
+
482
+ const deferredValues = useDeferredValue(formex.values);
483
+ const modified = formex.dirty;
484
+
485
+ const uniqueFieldValidator: CustomFieldValidator = useCallback(({
486
+ name,
487
+ value,
488
+ property
489
+ }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
490
+ [dataSource, path, entityId]);
491
+
492
+ const validationSchema = useMemo(() => entityId
493
+ ? getYupEntitySchema(
494
+ entityId,
495
+ resolvedCollection.properties,
496
+ uniqueFieldValidator)
497
+ : undefined,
498
+ [entityId, resolvedCollection.properties, uniqueFieldValidator]);
499
+
500
+ useEffect(() => {
501
+ const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
502
+ if (modified) {
503
+ saveEntityToCache(key, deferredValues);
504
+ }
505
+ }, [deferredValues, modified, path, entityId, status]);
506
+
507
+ useOnAutoSave(autoSave, formex, lastSavedValues, save);
508
+
509
+ useEffect(() => {
510
+ if (!autoSave && !formex.isSubmitting && underlyingChanges && entity) {
511
+ // we update the form fields from the Firestore data
512
+ // if they were not touched
513
+ Object.entries(underlyingChanges).forEach(([key, value]) => {
514
+ const formValue = formex.values[key];
515
+ if (!equal(value, formValue) && !formex.touched[key]) {
516
+ console.debug("Updated value from the datasource:", key, value);
517
+ formex.setFieldValue(key, value !== undefined ? value : null);
518
+ }
519
+ });
520
+ }
521
+ }, [formex.isSubmitting, autoSave, underlyingChanges, entity, formex.values, formex.touched, formex.setFieldValue]);
522
+
523
+ const formFieldKeys = getFormFieldKeys(resolvedCollection);
524
+
525
+ const formFields = () => (
526
+ <FormLayout>
527
+ {formFieldKeys.map((key) => {
528
+ const property = resolvedCollection.properties[key];
529
+ if (property) {
530
+ const underlyingValueHasChanged: boolean =
531
+ !!underlyingChanges &&
532
+ Object.keys(underlyingChanges).includes(key) &&
533
+ formex.touched[key];
534
+ const disabled = (!autoSave && formex.isSubmitting) || isReadOnly(property) || Boolean(property.disabled);
535
+ const hidden = isHidden(property);
536
+ if (hidden) return null;
537
+ const widthPercentage = property.widthPercentage ?? 100;
538
+ const cmsFormFieldProps: PropertyFieldBindingProps<any, M> = {
539
+ propertyKey: key,
540
+ disabled,
541
+ property,
542
+ includeDescription: property.description || property.longDescription,
543
+ underlyingValueHasChanged: underlyingValueHasChanged && !autoSave,
544
+ context: formContext,
545
+ partOfArray: false,
546
+ minimalistView: false,
547
+ autoFocus: false
548
+ };
549
+
550
+ return (
551
+ <FormEntry propertyKey={key}
552
+ widthPercentage={widthPercentage}
553
+ key={`field_${key}`}>
554
+ <PropertyFieldBinding {...cmsFormFieldProps} />
555
+ </FormEntry>
556
+ );
557
+ }
558
+
559
+ const additionalField = resolvedCollection.additionalFields?.find(f => f.key === key);
560
+ if (additionalField && entity) {
561
+ const Builder = additionalField.Builder;
562
+ if (!Builder && !additionalField.value) {
563
+ throw new Error("When using additional fields you need to provide a Builder or a value");
564
+ }
565
+ const child = Builder
566
+ ? <Builder entity={entity} context={context}/>
567
+ : <div className={"w-full"}>
568
+ {additionalField.value?.({
569
+ entity,
570
+ context
571
+ })?.toString()}
572
+ </div>;
573
+
574
+ return (
575
+ <div key={`additional_${key}`} className={"w-full"}>
576
+ <LabelWithIconAndTooltip
577
+ propertyKey={key}
578
+ icon={<NotesIcon size={"small"}/>}
579
+ title={additionalField.name}
580
+ className={"text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>
581
+ <div
582
+ className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-scroll no-scrollbar")}>
583
+ <ErrorBoundary>
584
+ {child}
585
+ </ErrorBoundary>
586
+ </div>
587
+ </div>
588
+ );
589
+ }
590
+
591
+ console.warn(`Property ${key} not found in collection ${resolvedCollection.name} in properties or additional fields. Skipping.`);
592
+ return null;
593
+ }).filter(Boolean)}
594
+ </FormLayout>
595
+ )
596
+ ;
597
+
598
+ const formRef = useRef<HTMLDivElement>(null);
599
+
600
+ const formView = <ErrorBoundary>
601
+ <>
602
+ <div className={"w-full py-2 flex flex-col items-start mt-4 lg:mt-8 mb-8"}>
603
+ <Typography
604
+ className={"py-4 flex-grow line-clamp-1 " + (collection.hideIdFromForm ? "mb-2" : "mb-0")}
605
+ variant={"h4"}>
606
+ {title ?? collection.singularName ?? collection.name}
607
+ </Typography>
608
+ <Alert color={"base"} className={"w-full"} size={"small"}>
609
+ <code
610
+ className={"text-xs select-all text-text-secondary dark:text-text-secondary-dark"}>
611
+ {entity?.path ?? path}/{entityId}
612
+ </code>
613
+ </Alert>
614
+ </div>
615
+
616
+ {!collection.hideIdFromForm &&
617
+ <CustomIdField customId={collection.customId}
618
+ entityId={entityId}
619
+ status={status}
620
+ onChange={setEntityId}
621
+ error={entityIdError}
622
+ loading={customIdLoading}
623
+ entity={entity}/>
624
+ }
625
+
626
+ {entityId && formContext && <>
627
+ <div className="mt-12 flex flex-col gap-8" ref={formRef}>
628
+ {formFields()}
629
+ <ErrorFocus containerRef={formRef}/>
630
+ </div>
631
+ </>}
632
+
633
+ {forceActionsAtTheBottom && <div className="h-16"/>}
634
+ </>
635
+ </ErrorBoundary>;
636
+
637
+ useEffect(() => {
638
+ if (entityId && onIdChange)
639
+ onIdChange(entityId);
640
+ }, [entityId, onIdChange]);
641
+
642
+ return (
643
+ <FormLayoutInner
644
+ className={className}
645
+ id={`form_${path}`}
646
+ pluginActions={pluginActions}
647
+ forceActionsAtTheBottom={forceActionsAtTheBottom}
648
+ formContext={formContext}>
649
+ {formView}
650
+ </FormLayoutInner>
651
+ );
652
+ }
653
+
654
+ function getInitialEntityValues<M extends object>(
655
+ collection: EntityCollection,
656
+ path: string,
657
+ status: "new" | "existing" | "copy",
658
+ entity: Entity<M> | undefined
659
+ ): Partial<EntityValues<M>> {
660
+ const resolvedCollection = resolveCollection({
661
+ collection,
662
+ path,
663
+ values: entity?.values,
664
+ });
665
+ const properties = resolvedCollection.properties;
666
+ if ((status === "existing" || status === "copy") && entity) {
667
+ return entity.values ?? getDefaultValuesFor(properties);
668
+ } else if (status === "new") {
669
+ return getDefaultValuesFor(properties);
670
+ } else {
671
+ console.error({
672
+ status,
673
+ entity
674
+ });
675
+ throw new Error("Form has not been initialised with the correct parameters");
676
+ }
677
+ }
678
+
679
+ export function yupToFormErrors(yupError: ValidationError): Record<string, any> {
680
+ let errors: Record<string, any> = {};
681
+ if (yupError.inner) {
682
+ if (yupError.inner.length === 0) {
683
+ return setIn(errors, yupError.path!, yupError.message);
684
+ }
685
+ for (const err of yupError.inner) {
686
+ if (!getIn(errors, err.path!)) {
687
+ errors = setIn(errors, err.path!, err.message);
688
+ }
689
+ }
690
+ }
691
+ return errors;
692
+ }
693
+
694
+ export function FormLayoutInner<M extends object>({
695
+ id,
696
+ formContext,
697
+ children,
698
+ className,
699
+ forceActionsAtTheBottom,
700
+ pluginActions
701
+ }: {
702
+ id?: string,
703
+ formContext: FormContext,
704
+ children: React.ReactNode,
705
+ className?: string,
706
+ forceActionsAtTheBottom?: boolean,
707
+ pluginActions?: React.ReactNode[],
708
+ }) {
709
+
710
+ const context = useFireCMSContext();
711
+ const sideEntityController = useSideEntityController();
712
+ const authController = useAuthController();
713
+
714
+ const formex = formContext.formex;
715
+ const collection = formContext.collection;
716
+ const path = formContext.path;
717
+ const entity = formContext.entity;
718
+ const savingError = formContext.savingError;
719
+ const status = formContext.status;
720
+ const openEntityMode = formContext.openEntityMode;
721
+ const setPendingClose = formContext.setPendingClose;
722
+ const disabled = formex.isSubmitting || (!formex.dirty && status === "existing");
723
+
724
+ if (!collection || !path) {
725
+ throw Error("INTERNAL: Collection and path must be defined in form context");
726
+ }
727
+
728
+ const getActionsForEntity = useCallback(({
729
+ entity,
730
+ customEntityActions
731
+ }: {
732
+ entity?: Entity<M>,
733
+ customEntityActions?: EntityAction[]
734
+ }): EntityAction[] => {
735
+
736
+ const createEnabled = canCreateEntity(collection, authController, path, null);
737
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity) : false;
738
+ const actions: EntityAction[] = [];
739
+ if (createEnabled)
740
+ actions.push(copyEntityAction);
741
+ if (deleteEnabled)
742
+ actions.push(deleteEntityAction);
743
+ if (customEntityActions)
744
+ return mergeEntityActions(actions, customEntityActions);
745
+ return actions;
746
+ }, [authController, collection, path]);
747
+
748
+ const entityActions = getActionsForEntity({
749
+ entity,
750
+ customEntityActions: collection.entityActions
751
+ });
752
+ const formActions = entityActions.filter(a => a.includeInForm === undefined || a.includeInForm);
753
+
754
+ const dialogActions = forceActionsAtTheBottom
755
+ ? buildBottomActions({
756
+ savingError,
757
+ entity,
758
+ formActions,
759
+ collection,
760
+ context,
761
+ sideEntityController,
762
+ isSubmitting: formex.isSubmitting,
763
+ disabled,
764
+ status,
765
+ setPendingClose,
766
+ pluginActions,
767
+ openEntityMode
768
+ })
769
+ : buildSideActions({
770
+ savingError,
771
+ entity,
772
+ formActions,
773
+ collection,
774
+ context,
775
+ sideEntityController,
776
+ isSubmitting: formex.isSubmitting,
777
+ disabled,
778
+ status,
779
+ pluginActions,
780
+ openEntityMode
781
+ });
782
+
783
+ return (
784
+ <Formex value={formContext.formex}>
785
+ <form
786
+ onSubmit={formContext.formex.handleSubmit}
787
+ onReset={() => formex.resetForm({
788
+ values: getInitialEntityValues(collection, path, status, entity),
789
+ })}
790
+ noValidate
791
+ className={cls("flex-1 flex flex-row w-full overflow-y-auto justify-center", className)}>
792
+ <div
793
+ id={id}
794
+ className={cls("relative flex flex-row max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-6xl w-full h-fit")}>
795
+
796
+ <div className={cls("flex flex-col w-full pt-12 pb-16 px-4 sm:px-8 md:px-10")}>
797
+
798
+ {formContext.formex.dirty
799
+ ? <Tooltip title={"Unsaved changes"}
800
+ className={"self-end sticky top-4 z-10"}>
801
+ <Chip size={"small"} colorScheme={"orangeDarker"}>
802
+ <EditIcon size={"smallest"}/>
803
+ </Chip>
804
+ </Tooltip>
805
+ : <Tooltip title={"In sync with the database"}
806
+ className={"self-end sticky top-4 z-10"}>
807
+ <Chip size={"small"}>
808
+ <CheckIcon size={"smallest"}/>
809
+ </Chip>
810
+ </Tooltip>}
811
+ {children}
812
+
813
+ </div>
814
+
815
+ </div>
816
+ {dialogActions}
817
+ </form>
818
+
819
+ </Formex>
820
+ );
821
+ }
822
+
823
+ type ActionsViewProps<M extends object> = {
824
+ savingError: Error | undefined,
825
+ entity: Entity<M> | undefined,
826
+ formActions: EntityAction[],
827
+ collection: ResolvedEntityCollection,
828
+ context: FireCMSContext,
829
+ sideEntityController: SideEntityController,
830
+ isSubmitting: boolean,
831
+ disabled: boolean,
832
+ status: "new" | "existing" | "copy",
833
+ setPendingClose?: (value: boolean) => void,
834
+ pluginActions?: React.ReactNode[],
835
+ openEntityMode: "side_panel" | "full_screen";
836
+ };
837
+
838
+ function buildBottomActions<M extends object>({
839
+ savingError,
840
+ entity,
841
+ formActions,
842
+ collection,
843
+ context,
844
+ sideEntityController,
845
+ isSubmitting,
846
+ disabled,
847
+ status,
848
+ setPendingClose,
849
+ pluginActions,
850
+ openEntityMode
851
+ }: ActionsViewProps<M>) {
852
+
853
+ const canClose = openEntityMode === "side_panel";
854
+ return <DialogActions position={"absolute"}>
855
+ {savingError &&
856
+ <div className="text-right">
857
+ <Typography color={"error"}>{savingError.message}</Typography>
858
+ </div>
859
+ }
860
+ {entity && formActions.length > 0 && <div className="flex-grow flex overflow-auto no-scrollbar">
861
+ {formActions.map(action => (
862
+ <IconButton
863
+ key={action.name}
864
+ color="primary"
865
+ onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
866
+ event.stopPropagation();
867
+ if (entity)
868
+ action.onClick({
869
+ entity,
870
+ fullPath: collection.path,
871
+ collection: collection,
872
+ context,
873
+ sideEntityController,
874
+ openEntityMode: openEntityMode
875
+ });
876
+ }}>
877
+ {action.icon}
878
+ </IconButton>
879
+ ))}
880
+ </div>}
881
+ {pluginActions}
882
+ <Button variant="text" disabled={disabled || isSubmitting} type="reset">
883
+ {status === "existing" ? "Discard" : "Clear"}
884
+ </Button>
885
+ <Button variant={canClose ? "text" : "filled"} color="primary" type="submit"
886
+ disabled={disabled || isSubmitting}
887
+ onClick={() => {
888
+ setPendingClose?.(false);
889
+ }}>
890
+ {status === "existing" && "Save"}
891
+ {status === "copy" && "Create copy"}
892
+ {status === "new" && "Create"}
893
+ </Button>
894
+ {canClose && <LoadingButton variant="filled" color="primary" type="submit" loading={isSubmitting}
895
+ disabled={disabled} onClick={() => {
896
+ setPendingClose?.(true);
897
+ }}>
898
+ {status === "existing" && "Save and close"}
899
+ {status === "copy" && "Create copy and close"}
900
+ {status === "new" && "Create and close"}
901
+ </LoadingButton>}
902
+ </DialogActions>;
903
+ }
904
+
905
+ function buildSideActions<M extends object>({
906
+ savingError,
907
+ entity,
908
+ formActions,
909
+ collection,
910
+ context,
911
+ sideEntityController,
912
+ isSubmitting,
913
+ disabled,
914
+ status,
915
+ setPendingClose,
916
+ pluginActions
917
+ }: ActionsViewProps<M>) {
918
+
919
+ return <div
920
+ 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)}>
921
+ <LoadingButton fullWidth={true} variant="filled" color="primary" type="submit" size={"large"}
922
+ disabled={disabled || isSubmitting} onClick={() => {
923
+ setPendingClose?.(false);
924
+ }}>
925
+ {status === "existing" && "Save"}
926
+ {status === "copy" && "Create copy"}
927
+ {status === "new" && "Create"}
928
+ </LoadingButton>
929
+ <Button fullWidth={true} variant="text" disabled={disabled || isSubmitting} type="reset">
930
+ {status === "existing" ? "Discard" : "Clear"}
931
+ </Button>
932
+
933
+ {pluginActions}
934
+
935
+ {savingError &&
936
+ <div className="text-right">
937
+ <Typography color={"error"}>{savingError.message}</Typography>
938
+ </div>
939
+ }
940
+ </div>;
941
+ }
942
+
943
+ function useOnAutoSave(autoSave: undefined | boolean, formex: FormexController<any>, lastSavedValues: any, save: (values: EntityValues<any>) => Promise<void>) {
944
+ if (!autoSave) return;
945
+ useEffect(() => {
946
+ if (autoSave) {
947
+ if (formex.values && !equal(formex.values, lastSavedValues.current)) {
948
+ save(formex.values);
949
+ }
950
+ }
951
+ }, [formex.values]);
952
+ }