sourceafFormBean::FormBean.fan

using afIoc
using afBedSheet
using afBeanUtils
using web

** Represents a Fantom object that can rendered as a HTML form, and reconstituted back to a Fantom object.  
class FormBean {    
    @Inject private const   Registry        registry
    @Inject private const   ObjCache        objCache
    @Inject private const   InputSkins      inputSkins
    @Inject private const   ValueEncoders   valueEncoders
    @Inject private const   Messages        _messages
    
    ** The bean type this 'FormBean' represents.
                    const   Type            beanType
    
    ** The message map used to find strings for labels, placeholders, hints, and validation errors.  
    ** Messages are read from (and overridden by) the following pod resource files: 
    **  - '<beanType.name>.properties' 
    **  - 'FormBean.properties'
    ** Note that property files must be in the same pod as the defining bean. 
                            Str:Str         messages    := Str:Str[:] { caseInsensitive=true }
    
    ** The form fields that make up this form bean.
    ** The returned map is read only, but the 'FormFields' themselves may still be manipulated.
                            Field:FormField formFields  := Field:FormField[:] { ordered=true } { get { &formFields.ro } private set }
    
    ** You may set any extra (cross field validation) error messages here. 
    ** They will be rendered along with the form field error messages.
                            Str[]           errorMsgs   := Str[,]
    
    ** The 'ErrorSkin' used to render error messages.
    @Inject { optional = true}
                            ErrorSkin?      errorSkin
    
    ** Deconstructs the given form bean type to a map of 'FormFields'. 
    new make(Type beanType, |This| in) {
        this.beanType = beanType
        in(this)    // need the objCache

        this.messages = _messages.getMessages(beanType)
        
        // create formfields with default values
        beanType.fields.findAll { it.hasFacet(HtmlInput#) }.each |field| {
            input := (HtmlInput) Slot#.method("facet").callOn(field, [HtmlInput#])  // Stoopid F4
            &formFields[field] = FormField {
                it.field            = field
                it.valueEncoder     = objCache[input.valueEncoder]
                it.inputSkin        = objCache[input.inputSkin]
                it.optionsProvider  = objCache[input.optionsProvider]
            }
        }
    }
    
    ** Renders form field errors (if any) to an unordered list.
    ** Delegates to a default instance of 'ErrorSkin' which renders the following HTML:
    ** 
    **   <div class='formBean-errors'>
    **       <div class='formBean-banner'>#BANNER</div>
    **       <ul>
    **           <li> Error 1 </li>
    **           <li> Error 2 </li>
    **       </ul>
    **   </div>
    ** 
    ** To change the banner message, set a message with the key 'errors.banner'.
    Str renderErrors() {
        ((ErrorSkin) (errorSkin ?: DefaultErrorSkin())).render(this)
    }

    ** Renders the form bean to a HTML form.
    ** 
    ** If the given 'bean' is 'null' then values are taken from the form fields. 
    ** Do so if you're re-rendering a form with validation errors.
    Str renderBean(Obj? bean) {
        if (bean != null && !bean.typeof.fits(beanType))
            throw Err("Bean '${bean.typeof.qname}' is not of FormBean type '${beanType.qname}'")
        inErr   := hasErrors
        html    := Str.defVal
        &formFields.each |formField, field| {
            skinCtx := SkinCtx {
                it.bean         = bean
                it.field        = field
                it.formBean     = this
                it.formField    = formField
                it.inErr        = inErr
                it.valueEncoders= this.valueEncoders
            }
            
            html += formField.inputSkin != null ? formField.inputSkin.render(skinCtx) : inputSkins.render(skinCtx)
        }
        return html
    }

    ** Renders a simple submit button.
    ** 
    **   <div class='formBean-row submitRow'>
    **     <input type='submit' name='formBeanSubmit' class='submit' value='label'>
    **   </div>
    ** 
    ** The label is taken from the msg key 'field.submit.label' and defaults to 'Submit'.
    Str renderSubmit() {
        buf := StrBuf()
        out := WebOutStream(buf.out)

        label := _msg("field.submit.label") ?: "Submit"
        out.div("class='formBean-row submitRow'")
        out.submit("name=\"formBeanSubmit\" class=\"submit\" value=\"${label.toXml}\"")
        out.divEnd

        return buf.toStr
    }
    
    ** Populates the form fields with values from the given 'form' map and performs server side validation.
    ** Error messages are saved to the form fields.
    ** 
    ** Returns 'true' if all the values are valid, 'false' if not.
    **  
    ** It is safe to pass in 'HttpRequest.form()' directly.
    Bool validateForm(Str:Str form) {
        &formFields.each |formField, field| {
            input       := formField.input
            formValue   := (Str?) form[field.name]?.trim
            hasValue    := formValue != null && !formValue.isEmpty

            // save the value in-case we have error and have to re-render
            formField.formValue = formValue

            if (input.required)
                if (formValue == null || formValue.isEmpty)
                    { _addErr(formField, formValue, "required", Str.defVal); return }
            
            if (hasValue && input.minLength != null)
                if (formValue.size < input.minLength)
                    { _addErr(formField, formValue, "minLength", input.minLength); return }

            if (hasValue && input.maxLength != null)
                if (formValue.size > input.maxLength)
                    { _addErr(formField, formValue, "maxLength", input.maxLength); return }

            if (hasValue && input.min != null) {
                if (formValue.toInt(10, false) == null)
                    { _addErr(formField, formValue, "notNum", Str.defVal); return }
                if (formValue.toInt < input.min)
                    { _addErr(formField, formValue, "min", input.min); return }
            }

            if (hasValue && input.max != null) {
                if (formValue.toInt(10, false) == null)
                    { _addErr(formField, formValue, "notNum", Str.defVal); return }
                if (formValue.toInt > input.max)
                    { _addErr(formField, formValue, "max", input.max); return }
            }

            if (hasValue && input.pattern != null)
                if (!"^${input.pattern}\$".toRegex.matches(formValue))
                    { _addErr(formField, formValue, "pattern", input.pattern); return }         
        }
        
        return !hasErrors
    }

    ** Creates an instance of 'beanType' with all the field values set to the form field values.
    ** Uses 'afBeanUtils::BeanProperties.create()'.
    ** 
    ** This should only be called after 'validateForm()'.
    ** 
    ** Any extra properties passed in will also be set.
    Obj createBean([Str:Obj?]? extraProps := null) {
        beanProps := _gatherBeanProperties(extraProps)
        return BeanProperties.create(beanType, beanProps, null) { IocBeanFactory(registry, it) }
    }

    ** Updates the given bean instance with form field values.
    ** Uses 'afBeanUtils::BeanProperties'.
    ** 
    ** This should only be called after 'validateForm()'.
    ** 
    ** Any extra properties passed in will also be set.
    Obj updateBean(Obj bean, [Str:Obj?]? extraProps := null) {
        if (!bean.typeof.fits(beanType))
            throw Err("Bean '${bean.typeof.qname}' is not of FormBean type '${beanType.qname}'")
        beanProps := _gatherBeanProperties(extraProps)

        reg := registry
        factory := BeanPropertyFactory {
            it.makeFunc  = |Type type->Obj| { IocBeanFactory(reg, type).create }.toImmutable
        }
        beanProps.each |value, expression| {
            // if a value wasn't submitted, it's not in the list, therefore set all beanProps
            factory.parse(expression).set(bean, value)
        }
        return bean
    }
    
    ** Returns 'true' if any form fields are in error, or if any extra error messages have been added to this
    Bool hasErrors() {
        !errorMsgs.isEmpty || &formFields.vals.any { it.invalid }
    }
    
    internal Str? _msg(Str key) {
        messages[key]
    }

    private Void _addErr(FormField formField, Str? value, Str type, Obj constraint) {
        field   := formField.field
        input   := (HtmlInput) Slot#.method("facet").callOn(field, [HtmlInput#])    // Stoopid F4
        label   := input.label ?: (_msg("field.${field.name}.label") ?: field.name.toDisplayName)
        errMsg  := _msg("field.${field.name}.${type}") ?: _msg("field.${type}")

        errMsg = errMsg
            .replace("\${label}", label)
            .replace("\${constraint}", constraint.toStr)
            .replace("\${value}", value ?: Str.defVal)

        formField.errMsg = errMsg
    }
    
    private Str:Obj? _gatherBeanProperties([Str:Obj?]? extraProps) {
        beanProps := Str:Obj?[:]
        &formFields.each |formField, field| {
            value := null

            // fugging checkboxes don't send unchecked data
            if (formField.formValue == null && formField.input.type.equalsIgnoreCase("checkbox"))
                beanProps[field.name] = false

            // other fields that weren't submitted are also null
            if (formField.formValue != null) {
                value = (formField.valueEncoder != null) ? formField.valueEncoder.toValue(formField.formValue) : valueEncoders.toValue(field.type, formField.formValue)
                beanProps[field.name] = value
            }
        }

        if (extraProps != null)
            beanProps.addAll(extraProps)
        
        return beanProps
    }
}