Back in the state chapter, a textbox component was built out of the low-level Bonsai primitives. Textboxes are an example of form elements, and they’re very common in real-world applications. Because of this, Bonsai has an entire library dedicated to building and combining forms.
The library is called
bonsai_web_ui_form
,
and it is built on top of the
same primitives that were used
in the previous chapter.
For the rest of this doc, this module alias will be in effect:
module Form = Bonsai_web_ui_form
The primary type in the forms
library is
'a Form.t
. A value
of type 'a Form.t
represents the state of a form
at one particular instant in
time, where the form in question
can edit values of type
'a
.
Because of the inherently
stateful nature of form UIs, it
is common to find
'a Form.t
inside of
Computation.t
. For
example, a textbox form element
that produces strings has this
type:
val Form.Elements.Textbox.string
: Source_code_position.tstring Form.t Computation.t ->
And the type for a checkbox that produces bools has this type:
val Form.Elements.Checkbox.bool
: Source_code_position.tbool
-> default:bool Form.t Computation.t ->
There are three primary
operations that can be performed
on a 'a Form.t
val Form.value: 'a Form.t -> 'a Or_error.t
val Form.view_as_vdom: 'a Form.t -> Vdom.Node.t
val Form.set: 'a Form.t -> 'a -> unit Vdom.Effect.t
Let’s look at them all in action, using the textbox component up above as an example:
The “value” of a
'a Form.t
is the
current output of the form as
filled in by the user. For a
simple textbox, that value would
be string
, but most
forms are compositions of
subforms, so the produced value
can be a record or variant.
In the following example, the value of a textbox is extracted and printed as a sexp:
let textbox_value =
let%sub textbox = Form.Elements.Textbox.string () in
let%arr textbox = textbox >>| Form.label "my textbox" in
let value = Form.value textbox in
Vdom.Node.div
[ Form.view_as_vdom textboxstring Or_error.t] value)
; Vdom.Node.sexp_for_debugging ([%sexp_of:
] ;;
Forms returning their values
inside of an
Or_error.t
may be
surprising at first, but in
practice, more complex forms are
fallible, either because form
validation for an element has
failed, or because a large form
is only partially filled out. By
making the assumption that
all forms are fallible,
the rest of the API is
simpler.
This one is pretty simple,
view_as_vdom
renders the form into a
Vdom.Node.t
.
However, it also has an optional
parameter that makes submitting
the form easier. Its full type
signature is:
module Submit : sig
type 'a t
val create
bool
: ?handle_enter:string option
-> ?button:unit Ui_effect.t)
-> f:('a -> unit
->
-> 'a tend
val view_as_vdom : ?on_submit:'a Submit.t -> 'a t -> Vdom.Node.t
Because forms are frequently
paired with a “submit” button,
the optional submission options
provide an easy way to submit
the form, with the
f
field being
called with the value of the
fully-validated form. The two
options for submitting the form
are
handle_enter
,
when true
will
cause the form to be submitted
whenever the
<enter>
key
hit when focusing a form element
inside this form.button
, if
Some
, a button with
the provided text will be added
to the form. This button will be
disabled whenever the form is in
an error state, but when the
form is valid, the button will
be enabled and will trigger the
submission function when
pressed.let textbox_on_submit =
let%sub textbox = Form.Elements.Textbox.string () in
let%arr textbox = textbox in
textbox"text to alert"
|> Form.label
|> Form.view_as_vdom ~on_submit:(Form.Submit.create () ~f:alert) ;;
Setting the contents of a form is a rarer requirement. Most forms are read-only (the user is the only one filling it out), but sometimes, a form should be modified by the program, perhaps to initialize the form in a specific state.
let form_set =
let%sub textbox = Form.Elements.Textbox.string () in
let%arr textbox = textbox >>| Form.label "my textbox" in
Vdom.Node.div
[ Form.view_as_vdom textbox
; Vdom.Node.buttonfun _ -> Form.set textbox "hello world"))
~attr:(Vdom.Attr.on_click ("click me" ]
[ Vdom.Node.text
] ;;
Most forms contain many input elements, and Bonsai-Forms comes with a set of combinators for combining many smaller subforms into a larger form.
For this example, we’ll build a form for the following type:
type t =
string
{ some_string : int
; an_int : bool
; on_or_off :
} [@@deriving typed_fields, sexp_of]
Building a form that produces
values of this type requires the
use of the
typed_fields
ppx,
which you’ll need to add to your
jbuild. Deriving
typed_fields
will
make a module named
Typed_field
containing a type with a
constructor representing each
field in the record it was
derived on.
let form_of_t : t Form.t Computation.t =
Form.Typed.Record.makemodule struct
((* reimport the module that typed_fields just derived *)
module Typed_field = Typed_field
let label_for_field = `Inferred
(* provide a form computation for each field in the record *)
let form_for_field : type a. a Typed_field.t -> a Form.t Computation.t = function
string ()
| Some_string -> Form.Elements.Textbox.int ~default:0 ~step:1 ()
| An_int -> Form.Elements.Number.bool ~default:false ()
| On_or_off -> Form.Elements.Checkbox.
;;end)
;;
We can also do the same for
variants with
[@@deriving typed_variants]
.
type v =
| Aof int
| B of string
| C
[@@deriving typed_variants, sexp_of]
let form_of_v : v Form.t Computation.t =
Form.Typed.Variant.makemodule struct
((* reimport the module that typed_fields just derived *)
module Typed_variant = Typed_variant_of_v
let label_for_variant = `Inferred
(* provide a form computation for constructor in the variant *)
let form_for_variant : type a. a Typed_variant.t -> a Form.t Computation.t
function
=
| A -> Bonsai.const (Form.return ())int ()
| B -> Form.Elements.Textbox.string ()
| C -> Form.Elements.Textbox.
;;end)
;;
Finally, using this new form and printing the results:
let view_for_form : Vdom.Node.t Computation.t =
let%sub form_t = form_of_t in
let%sub form_v = form_of_v in
let%arr form_t = form_t
and form_v = form_v in
let form = Form.both form_t form_v in
let value = Form.value form in
Vdom.Node.div
[ Form.view_as_vdom form
; Vdom.Node.sexp_for_debugging ([%sexp_of: (t * v) Or_error.t] value)
] ;;
Notably missing in the Forms
API is a “map” function. In its
place is
Form.project
, which
has this type signature:
val project
: 'a t
-> parse_exn:('a -> 'b)
-> unparse:('b -> 'a) -> 'b t
project
is a way
to move from a form producing
values of type 'a
to a form producing values of
type 'b
, but it
requires two “mapping”
functions,
parse_exn
, which
moves from 'a
to
'b
as you’d expect,
but the other,
unparse
, goes in
the opposite direction!
unparse
is
required because
Form.set
needs to
be able to accept values of type
'b
and route them
through the setter for the input
form.
In practice,
project
is used to
build forms for types that can
be parsed from other types. For
example, if
Form.Elements.Textbox.int
didn’t exist, we could implement
it like so:
let int_textbox : int Form.t Computation.t =
let%sub form = Form.Elements.Textbox.string () in
let%arr form = form in
Form.project form ~parse_exn:Int.of_string ~unparse:Int.to_string ;;
You’ll notice that
non-integers are reported as an
error. Form.project
actually captures the exception
thrown by
Int.of_string
and
the Form.value
returned by the
project
ed form is
an Error
.
On to Chapter 5: Effect