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.