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.
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.
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.
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.
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.
Open the library modules
open Thoth.Elmish
open Thoth.Elmish.FormBuilder
open Thoth.Elmish.FormBuilder.BasicFields
Register the message dedicated to the FormBuilder
type Msg =
| OnFormMsg of FormBuilder.Types.Msg
// ...
Store the FormBuilder instance in your model
type Model =
{ FormState : FormBuilder.Types.State
// ...
}
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
Initialize the FormBuilder in your init function
formConfig in your model
let private init _ =
let (formState, formCmds) = Form.init formConfig formState
{ FormState = formState }, Cmd.map OnFormMsg formCmds
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
// ...
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 }
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 ) ]
)
)
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 displayKey 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
In this section, you will learn:
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.
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:
This is the structure used in Thoth.Elmish.FormBuilder.BasicFields
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.
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
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
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
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
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
toJson functionThis 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
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
validate youself after updating your model. This is required because not every field message needs to trigger a validation.Cmd module from Elmish, you needs to use FormCmd. This module implements the same API as the Cmd module.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 ] ]
Expose your config
let config : FieldConfig =
{ View = view
Update = update
Init = init
Validate = validate
IsValid = isValid
ToJson = toJson
SetError = setError }
See the F.A.Q. for why I chose to expose a fluent API.
In order to design an immutable fluent API, you need to mark your constructor as private.
type BasicInput private (state : Input.State) =
Expose a static member Create(name : string)
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 }
Create a member to return a FieldBuilder
member __.WithDefaultView () : FieldBuilder =
{ Type = "basic-input"
State = state
Name = state.Name
Config = Input.config }
Notes
Type value needs to be a unique name to identify your field type. For example, basic-input, fulma-input, my-lib-special-dropdown, etc.Config properties refer to the exposed config you wrote earlier.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 ] }
You now have a working field with a flexible API exposed
| 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 |
| 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 |
| 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 |
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.
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 |
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
] ]
)
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.
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.