import React , { useCallback , useEffect , useMemo , useState } from "react" ; import { Box , Button , Container , Grid , Theme , Typography } from "@mui/material" ; import createStyles from "@mui/styles/createStyles" ; import makeStyles from "@mui/styles/makeStyles" ; import { CMSFormFieldProps , Entity , EntitySchema , EntitySchemaResolver , EntityStatus , EntityValues , FormContext , Properties , Property , ResolvedEntitySchema } from "../models" ; import { Form , Formik , FormikHelpers } from "formik" ; import { buildPropertyField } from "./form_factory" ; import { CustomFieldValidator , getYupEntitySchema } from "./validation" ; import deepEqual from "deep-equal" ; import { ErrorFocus } from "./components/ErrorFocus" ; import { computeSchema , initWithProperties , isHidden , isReadOnly } from "../core/utils" ; import { CustomIdField } from "./components/CustomIdField" ; import { useDataSource } from "../hooks" ; export const useStyles = makeStyles ( ( theme ) => createStyles ( { stickyButtons : { marginTop : theme . spacing ( 2 ) , background : theme . palette . mode === "light" ? "rgba(255,255,255,0.6)" : "rgba(255, 255, 255, 0)" , backdropFilter : "blur(4px)" , borderTop : theme . palette . divider , position : "sticky" , bottom : 0 , zIndex : 200 } , container : { padding : theme . spacing ( 4 ) , marginTop : theme . spacing ( 2 ) , marginBottom : theme . spacing ( 2 ) , [ theme . breakpoints . down ( "lg" ) ] : { paddingLeft : theme . spacing ( 2 ) , paddingRight : theme . spacing ( 2 ) , paddingTop : theme . spacing ( 3 ) , paddingBottom : theme . spacing ( 3 ) } , [ theme . breakpoints . down ( "md" ) ] : { padding : theme . spacing ( 2 ) } } , button : { margin : theme . spacing ( 1 ) } , form : { marginTop : theme . spacing ( 2 ) } } ) ) ; category export interface EntityFormProps < M > { /**
     * New or existing status
     */ status : EntityStatus ; /**
     * Path of the collection this entity is located
     */ path : string ; /**
     * Use to resolve the schema properties for specific path, entity id or values
     */ schemaOrResolver : EntitySchema < M > & EntitySchemaResolver < M > ; /**
     * The updated entity is passed from the parent component when the underlying data
     * has changed in the datasource
     */ entity ? : Entity < M > ; /**
     * The callback function called when Save is clicked and validation is correct
     */ onEntitySave ? ( props ) : Promise < void > ; /**
     * The callback function called when discard is clicked
     */ onDiscard ? ( ) : void ; /**
     * The callback function when the form is dirty, so the values are different
     * from the original ones
     */ onModified ? ( dirty ) : void ; /**
     * The callback function when the form original values have been modified
     */ onValuesChanged ? ( values ? ) : void ; } param status param path param schemaOrResolver param entity param onEntitySave param onDiscard param onModified param onValuesChanged constructor category export function EntityForm < M > ( { status , path , schemaOrResolver , entity , onEntitySave , onDiscard , onModified , onValuesChanged } ) { const classes = useStyles ( ) ; const dataSource = useDataSource ( ) ; /**
     * Base values are the ones this view is initialized from, we use them to
     * compare them with underlying changes in the datasource
     */ const entityId = status === "existing" ? entity ?. id : undefined ; const initialResolvedSchema = useMemo ( ( ) => computeSchema ( { schemaOrResolver , path , entityId } ) , [ schemaOrResolver , path , entityId ] ) ; const baseDataSourceValues = useMemo ( ( ) => { const properties = initialResolvedSchema . properties ; if ( ( status === "existing" || status === "copy" ) && entity ) { return entity . values ?? initWithProperties ( properties , initialResolvedSchema . defaultValues ) ; } else if ( status === "new" ) { return initWithProperties ( properties , initialResolvedSchema . defaultValues ) ; } else { console . error ( { status , entity } ) ; throw new Error ( "Form has not been initialised with the correct parameters" ) ; } } , [ status , initialResolvedSchema , entity ] ) ; const formRef = React . useRef < HTMLDivElement > ( null ) ; const [ customId , setCustomId ] = React . useState < string | undefined > ( undefined ) ; const [ customIdError , setCustomIdError ] = React . useState < boolean > ( false ) ; const [ savingError , setSavingError ] = React . useState < any > ( ) ; const initialValuesRef = React . useRef < EntityValues < M > > ( entity ?. values ?? baseDataSourceValues as EntityValues < M > ) ; const initialValues = initialValuesRef . current ; const [ internalValue , setInternalValue ] = useState < EntityValues < M > | undefined > ( initialValues ) ; const schema = useMemo ( ( ) => computeSchema ( { schemaOrResolver , path , entityId , values : internalValue } ) , [ schemaOrResolver , path , entityId , internalValue ] ) ; const mustSetCustomId = ( status === "new" || status === "copy" ) && ! ! schema . customId ; const underlyingChanges = useMemo ( ( ) => { if ( initialValues && status === "existing" ) { return Object . keys ( schema . properties ) . map ( ( key ) => { const initialValue = ( initialValues as any ) [ key ] ; const latestValue = ( baseDataSourceValues as any ) [ key ] ; if ( ! deepEqual ( initialValue , latestValue ) ) { return { [ key ] : latestValue } ; } return { } ; } ) . reduce ( ( a , b ) => ( { ... a , ... b } ) , { } ) as Partial < EntityValues < M > > ; } else { return { } ; } } , [ initialValues , baseDataSourceValues ] ) ; const saveValues = useCallback ( ( values , formikActions ) => { if ( mustSetCustomId && ! customId ) { console . error ( "Missing custom Id" ) ; setCustomIdError ( true ) ; formikActions . setSubmitting ( false ) ; return ; } setSavingError ( null ) ; setCustomIdError ( false ) ; let savedEntityId ; if ( status === "existing" ) { if ( ! entity ?. id ) throw Error ( "Form misconfiguration when saving, no id for existing entity" ) ; savedEntityId = entity . id ; } else if ( status === "new" || status === "copy" ) { if ( schema . customId ) { if ( ! customId ) throw Error ( "Form misconfiguration when saving, customId should be set" ) ; savedEntityId = customId ; } } else { throw Error ( "New FormType added, check EntityForm" ) ; } if ( onEntitySave ) onEntitySave ( { schema : schema as EntitySchema , path , entityId : savedEntityId , values , previousValues : entity ?. values } ) . then ( _ => { initialValuesRef . current = values ; formikActions . setTouched ( { } ) ; } ) . catch ( e => { console . error ( e ) ; setSavingError ( e ) ; } ) . finally ( ( ) => { formikActions . setSubmitting ( false ) ; } ) ; } , [ status , path , schema , entity , onEntitySave , mustSetCustomId , customId ] ) ; const uniqueFieldValidator = useCallback ( ( { name , value , property } ) => dataSource . checkUniqueField ( path , name , value , property , entityId ) , [ dataSource , path , entityId ] ) ; const validationSchema = useMemo ( ( ) => getYupEntitySchema ( schema . properties , uniqueFieldValidator ) , [ schema . properties ] ) ; return ( < Formik initialValues = { initialValues } onSubmit = { saveValues } validationSchema = { validationSchema } validate = { ( values ) => console . debug ( "Validating" , values ) } onReset = { ( ) => onDiscard && onDiscard ( ) } > { ( { values , touched , setFieldValue , handleSubmit , isSubmitting , dirty } ) => { return < FormInternal baseDataSourceValues = { baseDataSourceValues } values = { values } onModified = { onModified } setInternalValue = { setInternalValue } onValuesChanged = { onValuesChanged } underlyingChanges = { underlyingChanges } entityId = { entityId } entity = { entity } touched = { touched } setFieldValue = { setFieldValue } schema = { schema } isSubmitting = { isSubmitting } classes = { classes } formRef = { formRef } status = { status } setCustomId = { setCustomId } customIdError = { customIdError } handleSubmit = { handleSubmit } savingError = { savingError } / > ; } } < /Formik>); } function FormInternal < M > ( { baseDataSourceValues , values , onModified , setInternalValue , onValuesChanged , underlyingChanges , entity , touched , setFieldValue , schema , entityId , isSubmitting , classes , formRef , status , setCustomId , customIdError , handleSubmit , savingError } ) { const modified = useMemo ( ( ) => ! deepEqual ( baseDataSourceValues , values ) , [ baseDataSourceValues , values ] ) ; useEffect ( ( ) => { if ( onModified ) onModified ( modified ) ; setInternalValue ( values ) ; if ( onValuesChanged ) onValuesChanged ( values ) ; } , [ modified , values ] ) ; if ( underlyingChanges && entity ) { Object . entries ( underlyingChanges ) . forEach ( ( [ key , value ] ) => { const formValue = ( values as any ) [ key ] ; if ( ! deepEqual ( value , formValue ) && ! ( touched as any ) [ key ] ) { console . debug ( "Updated value from the datasource:" , key , value ) ; setFieldValue ( key , value !== undefined ? value : null ) ; } } ) ; } const context = { schema , entityId , values } ; const formFields = ( < Grid container spacing = { 4 } > { Object . entries < Property > ( schema . properties as Properties ) . filter ( ( [ key , property ] ) => ! isHidden ( property ) ) . map ( ( [ key , property ] ) => { const underlyingValueHasChanged = ! ! underlyingChanges && Object . keys ( underlyingChanges ) . includes ( key ) && ! ! ( touched as any ) [ key ] ; const shouldAlwaysRerender = typeof ( schema . originalSchema . properties as any ) [ key ] === "function" ; const disabled = isSubmitting || isReadOnly ( property ) || Boolean ( property . disabled ) ; const cmsFormFieldProps = { name : key , disabled : disabled , property : property , includeDescription : true , underlyingValueHasChanged : underlyingValueHasChanged , context : context , tableMode : false , partOfArray : false , autoFocus : false , shouldAlwaysRerender : shouldAlwaysRerender } ; return ( < Grid item xs = { 12 } id = { `form_field_${ key }` } key = { `field_${ schema . name }_${ key }` } > { buildPropertyField ( cmsFormFieldProps ) } < /Grid>); } ) } < /Grid>); return ( < Container maxWidth = { "sm" } className = { classes . container } ref = { formRef } > < CustomIdField schema = { schema as EntitySchema } status = { status } onChange = { setCustomId } error = { customIdError } entity = { entity } / > < Form className = { classes . form } onSubmit = { handleSubmit } noValidate > < Box pt = { 3 } > { formFields } < /Box> < div className = { classes . stickyButtons } > { savingError && < Box textAlign = "right" > < Typography color = { "error" } > { savingError . message } < /Typography> < /Box>} { buildButtons ( classes , isSubmitting , modified , status ) } < /div> < /Form> < ErrorFocus containerRef = { formRef } / > < /Container>); } function buildButtons ( classes , isSubmitting , modified , status ) { const disabled = isSubmitting || ( ! modified && status === "existing" ) ; return ( < Box textAlign = "right" > { status === "existing" && < Button variant = "text" color = "primary" disabled = { disabled } className = { classes . button } type = "reset" > Discard < /Button>} < Button variant = "contained" color = "primary" type = "submit" disabled = { disabled } className = { classes . button } > { status === "existing" && "Save" } { status === "copy" && "Create copy" } { status === "new" && "Create" } < /Button> < /Box>); } export default EntityForm ;