FormBuilder

This library is marked in Alpha stage but is already used in production.

I released it in Alpha so we can work as a community on improving it and still be able to introduce changes if needed.

Introduction

When working with forms in an Elmish application, we end up writing a lot of lines. I explained the situation in my keynote at FableConf 2018.

The conclusion was: that to manage a basic form we need to write at least 23 lines of code per field and have a lot of duplication.

This library is trying to solve that problem.

Demo

How to use ?

Installation

Add the Thoth.Elmish.FormBuilder dependency in your Paket files: paket add Thoth.Elmish.FormBuilder --project <your project>.

If you are trying this library for the first time you probably want to add Thoth.Elmish.FormBuilder.BasicFields too. It provides some ready to use fields.

In order to use the default view of Thoth.Elmish.FormBuilder.BasicFields, you need to include Bulma in your project.

BasicFields - Basic usage

Information

In this part, we are going to use Thoth.Elmish.FormBuilder.BasicFields in order to have ready to use fields.

Later, we will learn how to build custom fields.


  1. Open the library modules
open Thoth.Elmish
open Thoth.Elmish.FormBuilder
open Thoth.Elmish.FormBuilder.BasicFields

  1. Register the message dedicated to the FormBuilder
type Msg =
    | OnFormMsg of FormBuilder.Types.Msg
    // ...

  1. Store the FormBuilder instance in your model
type Model =
    { FormState : FormBuilder.Types.State
      // ...
    }

  1. Create your form using the builder API
let (formState, formConfig) =
    Form<Msg>
        .Create(OnFormMsg)
        .AddField(
            BasicInput
                .Create("name")
                .WithLabel("Name")
                .IsRequired()
                .WithDefaultView()
        )
        .AddField(
            BasicSelect
                .Create("favLang")
                .WithLabel("Favorite language")
                .WithValuesFromServer(getLanguages)
                .WithPlaceholder("")
                .IsRequired("I know it's hard but you need to choose")
                .WithDefaultView()
        )
        // When you are done with adding fields, you need to call `.Build()`
        .Build()

Each field needs to have a unique name. The name is used to link the label with its form elements. And it will also be used as the key for the JSON.

If you don't set a unique name per field, you will see this message in the console:

Each field needs to have a unique name. I found the following duplicate name:

- name
- description

  1. Initialize the FormBuilder in your init function
Never store formConfig in your model
let private init _ =
    let (formState, formCmds) = Form.init formConfig formState
    { FormState = formState }, Cmd.map OnFormMsg formCmds

  1. Handle OnFormMsg in your update function
let private update msg model =
    match msg with
    | OnFormMsg msg ->
        let (formState, formCmd) = Form.update formConfig msg model.FormState
        { model with FormState = formState }, Cmd.map OnFormMsg formCmd
    // ...

  1. Render your form in your view function
let private formActions (formState : FormBuilder.Types.State) dispatch =
    div [ ]
        [ button [ OnClick (fun _ ->
                    dispatch Submit
                   ) ]
            [ str "Submit" ] ]

let private view model dispatch =
    Form.render
        { Config = formConfig
          State = model.FormState
          Dispatch = dispatch
          ActionsArea = (formActions model.FormState dispatch)
          Loader = Form.DefaultLoader }

BasicFields - Custom views

If you are not using Bulma in your project, Thoth.Elmish.FormBuilder.BasicFields provides a WithCustomView API allowing you to customize the field view.

Example:

.AddField(
    BasicInput
        .Create("name")
        .WithLabel("Name")
        .IsRequired()
        .WithCustomView(fun (state : Types.FieldState) (dispatch : Types.IFieldMsg -> unit) ->
            let state : Input.State = state :?> Input.State

            // You can write your view here
            input [ Value  state.Value
                    OnChange (fun ev -> ev.Value |> Input.ChangeValue |> dispatch ) ]
        )
)

Server side validation

In order to support server side validation, the library defines the type ErrorDef.

type ErrorDef =
    { Text : string
      Key : string }
  • Text is the error message to display
  • Key is the name of the field related to the error.

I included the Decoder and Encoder definitions for use in your Fable client.

If you need it on the server, you will need to copy the type definition for now.

When you receive a ErrorDef list from your server, you can call Form.setErrors to display them in the form.

Example:

| CreationResponse.Errors errors ->
    let newFormState =
        model.State
        |> Form.setLoading false
        |> Form.setErrors formConfig errors
    { model with State = newFormState }, Cmd.none

Create a custom field

Prelude

In this section, you will learn:

  • how to create a custom fields
  • the convention I use when designing a field, I encourage you to follow them 😊
  • general comments on why I structure my code in a specific way

You will see usage of boxing box and casting :?>. If you want to learn more about that after reading this section you can take a look at the F.A.Q.

File structure

When designing a field I encourage you to follow this structure:

namespace MyCustomFieldLibrary

[<RequireQualifiedAccess>]
module MyField =

    // Here goes the logic for your field

type MyField private (state : MyField.State) =
    // Here goes the Fluent API that will be exposed and used to register a field in a Form

    static member Create(name : string) =
        // ...

By using this architecture, you can then use your API like this:

module MyApp.PageA

open Thoth.Elmish.FormBuilder
open MyCustomFieldLibrary

let formState, formConfig =
    Form<Msg>
    .Create(OnFormMsg)
        .AddField(
            MyField
                .Create("name")
                // ...
        )

The benefits are:

  • Each field consists of a single file
  • By using 1 open statement you get access to all your fields API

This is the structure used in Thoth.Elmish.FormBuilder.BasicFields

Implement your field logic and config contract

Designing a custom field is similar to designing an Elmish component.

Here is the contract that all fields need to implement. Don't worry, we are going to go step by step.

/// Contract for registering fields in the `Config`
type FieldConfig =
    { View : FieldState -> (IFieldMsg -> unit) -> React.ReactElement
      Update : FieldMsg -> FieldState -> FieldState * (string -> Cmd<Msg>)
      Init : FieldState -> FieldState * (string -> Cmd<Msg>)
      Validate : FieldState -> FieldState
      IsValid : FieldState -> bool
      ToJson : FieldState -> string * Encode.Value
      SetError : FieldState -> string -> FieldState }

As an example of a custom field, we will re-implement a basic Input.


  1. State and Validator types

State is similar to Model in Elmish terms

Every field needs to have a Name property. This will be used later to identify each field uniquely and to generate the JSON representation of the field.

type State =
    { Label : string
      Value : string
      Type : string
      Placeholder : string option
      Validators : Validator list
      ValidationState : ValidationState
      Name : string }

and Validator = State -> ValidationState

  1. Msg type

As in Elmish, your fields are going to react to Msg. But you need to interface with IFieldMsg.

type Msg =
    | ChangeValue of string
    interface IFieldMsg

  1. init function

This function will be called when initializing your forms.

For example, if your field needs to fetch data from the server you can trigger the request here. See the select field for an example

let private init (state : FieldState) =
    state, FormCmd.none

  1. validate and setError function

If you used the same names for ValidationState and Validators properties, you can copy/paste these functions in all your field definitions.

I didn't find a way to make it generic for any field

let private validate (state : FieldState) =
    let state : State = state :?> State
    let rec applyValidators (validators : Validator list) (state : State) =
        match validators with
            | validator::rest ->
                match validator state with
                | Valid -> applyValidators rest state
                | Invalid msg ->
                    { state with ValidationState = Invalid msg }
            | [] -> state

    applyValidators state.Validators { state with ValidationState = Valid } |> box

let private setError (state : FieldState) (message : string)=
    let state : State = state :?> State
    { state with ValidationState = Invalid message } |> box

  1. isValid function

This function will be called to check if your field is in a valid state or not.

let private isValid (state : FieldState) =
    let state : State = state :?> State
    state.ValidationState = Valid

  1. toJson function

This function will be called by the form in order to generate the JSON representation of your field.

let private toJson (state : FieldState) =
    let state : State = state :?> State
    state.Name, Encode.string state.Value

  1. update function

Similar to Elmish, this is called for updating your State when receiving a Msg for this field.

let private update (msg : FieldMsg) (state : FieldState) =
    // Cast the received message into it's real type
    let msg = msg :?> Msg
    // Cast the received state into it's real type
    let state = state :?> State

    match msg with
    | ChangeValue newValue ->
        { state with Value = newValue }
        |> validate
        // We need to box the returned state
        |> box, FormCmd.none

Notes

  • You need to call validate youself after updating your model. This is required because not every field message needs to trigger a validation.
  • Instead of using the Cmd module from Elmish, you needs to use FormCmd. This module implements the same API as the Cmd module.

  1. view function
let private view (state : FieldState) (dispatch : IFieldMsg -> unit) =
    let state : State = state :?> State
    let className =
        if isValid state then
            "input"
        else
            "input is-danger"

    div [ Class "field" ]
        [ label [ Class "label"
                  HtmlFor state.Name ]
            [ str state.Label ]
          div [ Class "control" ]
            [ input [ Value state.Value
                      Placeholder (state.Placeholder |> Option.defaultValue "")
                      Id state.Name
                      Class className
                      OnChange (fun ev ->
                        ChangeValue ev.Value |> dispatch
                      ) ] ]
          span [ Class "help is-danger" ]
            [ str state.ValidationState.Text ] ]

  1. Expose your config
let config : FieldConfig =
    { View = view
      Update = update
      Init = init
      Validate = validate
      IsValid = isValid
      ToJson = toJson
      SetError = setError }

Expose a fluent API

See the F.A.Q. for why I chose to expose a fluent API.

  1. In order to design an immutable fluent API, you need to mark your constructor as private.
type BasicInput private (state : Input.State) =

  1. Expose a static member Create(name : string)
Each field should have a name property as recommended in HTML5. This name will be used to identify the field for dispatching the messages in your form.
static member Create(name : string) =
    BasicInput
        { Label = ""
          Value = ""
          Type = "text"
          Placeholder = None
          Validators = [ ]
          ValidationState = Valid
          Name = name }

  1. Create a member to return a FieldBuilder
member __.WithDefaultView () : FieldBuilder =
    { Type = "basic-input"
      State = state
      Name = state.Name
      Config = Input.config }

Notes

  • The Type value needs to be a unique name to identify your field type. For example, basic-input, fulma-input, my-lib-special-dropdown, etc.
  • The Config properties refer to the exposed config you wrote earlier.

  1. Create on member per property you want to customize

Here are some examples:

member __.WithLabel (label : string) =
    BasicInput { state with Label = label }

member __.WithPlaceholder (placeholder : string) =
    BasicInput { state with Placeholder = Some placeholder }

member __.IsRequired (?msg : String) =
    let msg = defaultArg msg "This field is required"

    let validator (state : Input.State) =
        if String.IsNullOrWhiteSpace state.Value then
            Invalid msg
        else
            Valid

    BasicInput { state with Validators = state.Validators @ [ validator ] }

member __.AddValidator (validator) =
    BasicInput { state with Validators = state.Validators @ [ validator ] }
🎉 Congrats 🎉

You now have a working field with a flexible API exposed

API

FormBuilder.Types

Types Description
ErrorDef Error representation to support server side validation
ValidationState Used to describe if a field is Valid or Invalid with the message to display
IFieldMsg Interface to be implemented by any field Msg
FieldState Type alias for the field State, should be casted
FieldMsg Type alias for the field Msg, should be casted
Field Record to register a field in a Form instance
Msg Internal Msg used by the Form library
State Track current state of the Form
FieldConfig Contract for registering fields in the Config
Config Configuration for the Form

FormBuilder.Form

Types Description
Form.init init function to call from your init to initialize the form
Form.update update function to call when you received a message for the form
Form.render Render the form in your view
Form.valide Validate the model and check if it's valid
Form.toJson Generate a JSON representation from the current state
Form.setLoading Set the loading state of the form
Form.isLoading Check if the form is loading
Form.setErrors Set error for each field based on a ErrorDef list

FormBuilder.FormCmd

Types Description
FormCmd.none None - no commands, also known as []
FormCmd.ofMsg Command to issue a specific message
FormCmd.map When emitting the message, map to another type
FormCmd.batch Aggregate multiple commands
FormCmd.ofAsync Command that will evaluate an async block and map the result into success or error (of exception)
FormCmd.ofFunc Command to evaluate a simple function and map the result into success or error (of exception)
FormCmd.performFunc Command to evaluate a simple function and map the success to a message discarding any possible error
FormCmd.attemptFunc Command to evaluate a simple function and map the error (in case of exception)
FormCmd.ofPromise Command to call promise block and map the results

F.A.Q.

Can we avoid boxing / casting ?

This library is using boxing / casting a lot in order to allow us to store different types in a common list. I tried to use interface for FieldConfig in order to have something like:

type FieldConfig<'State, 'Msg> =
    abstract member View : 'State * ('Msg -> unit) -> obj
    abstract member Update : 'Msg * 'State -> 'State * (string -> Cmd<'Msg>)
    abstract member Init : 'State -> 'State * (string -> Cmd<'Msg>)
    abstract member Validate : 'State -> 'State
    abstract member IsValid : 'State -> bool
    abstract member ToJson : 'State -> string * Encode.Value
    abstract member SetError : 'State * string -> 'State

But then I didn't find a way to store all the fields FieldConfig<'State, 'Msg> in a list inside Config<'AppMsg>.

If you find a way to either hide the boxing / casting things from the user view or to make everything strongly typed please open an issue to discuss it.

Why use a fluent API ?

When writing this library I explored several ways for building the DSL. Here is my analysis:

Computation Expression Pipeline Fluent
Easy to create
Easy to extend
Terse
Discoverability
Naturally follows indentation
Allow optional arguments

Am I forced to use Bulma/Fulma ?

No, I used Bulma in Thoth.Elmish.FormBuilder.BasicFields because it was easier for me as I already know this framework. You can use WithCustomView to customize the views.

BasicInput
    .Create("condition")
    // Here you can customize the view function
    .WithCustomView(fun state dispatch ->
        let state = state :?> Checkbox.State

        div [ ]
            [ label [ Class "my-custom-label" ]
                [ str state.Label ]
              input [ Class "my-custom-input"
                      // others properties
                    ] ]
    )

Will there be a Fulma based library ?

Yes, I am already working on it but it's not ready yet for a public release, because I want to support all/most of Bulma features and it takes time to design.

Is there any CSS included ?

Thoth.Elmish.FormBuilder has been designed to be really thin and not tied to a specific CSS framework.

The only special case is if you use the DefaultLoader. Then the library will inject 7 lines of CSS in your document.

But if you use a CustomLoader then no CSS is injected.