DEV Community

Cover image for Doodle + Forms
Nicholas Eddy
Nicholas Eddy

Posted on • Updated on

Doodle + Forms

Doodle forms make data collection simple, while still preserving flexibility to build just the right experience. They hide a lot of the complexity associated with mapping visual components to fields, state management, and validation. The result is an intuitive metaphor modeled around the idea of a constructor.

Doodle also has a set of helpful forms controls that cover a reasonable range of data-types. These make its easy to create forms without much hassle. But there are bound to be cases where more customization is needed. This is why Doodle forms are also extensible, allowing you to fully customize the data they bind to and how each fields is visualized.

Like Constructors​

Forms are very similar to constructors in that they have typed parameter lists (fields), and can only "create" instances when all their inputs are valid. Like any constructor, a Form can have optional fields, default values, and arbitrary types for its fields.

While Forms behave like constructors in most ways, they do not actually create instances (only sub-forms do). This means they are not typed. Instead, they take fields and output a corresponding lists of strongly-typed data when all their fields are valid. This notification is intentionally general to allow forms to be used in a wide range of use cases.

Creation​

Forms are created using the Form builder function. This function ensures strong typing for fields and the form's "output".

The Form returned from the builder does not expose anything about the data it produces. So all consumption logic goes in the builder block.

val form = Form { this(
    field1,
    field2,
    // ...
    onInvalid = {
        // called whenever any fields is updated with invalid data
    }) { field1, field2, /*...*/ ->
        // called each time all fields are updated with valid data
    }
}
Enter fullscreen mode Exit fullscreen mode

Fields


Each field defined in the Form will be bounded to a single View. These views are defined during field binding using a FieldVisualizer. A visualizer is responsible for taking a Field and its initial state and returning a View. The visualizer then acts as the bridge between the field's state and the View, mapping changes made in the View to the field (this includes validating that input).

Field State​

Fields store their data as FieldState. This is a strongly-typed value that can be Valid or Invalid. Valid state contains a value, while invalid state does not. A Form with any invalid fields is invalid itself, and will indicate this by calling onInvalid.

Creating Fields​

Fields are created implicitly when FieldVisualizers are bound to a Form. These visualizers can be created using the field builder function, by implementing the interface, or by one of the existing form controls.

Using the builder DSL

import io.nacular.doodle.controls.form.field

field<T> {
    initial // initial state of the field
    state   // mutable state of the field

    view {} // view to display for the field
}
Enter fullscreen mode Exit fullscreen mode

Implementing interface

import io.nacular.doodle.controls.form.FieldInfo
import io.nacular.doodle.controls.form.FieldVisualizer

class MyVisualizer<T>: FieldVisualizer<T> {
    override fun invoke(fieldInfo: FieldInfo<T>): View {
        fieldInfo.initial // initial state of the field
        fieldInfo.state   // mutable state of the field

        return view {}    // view to display for the field
    }
}
Enter fullscreen mode Exit fullscreen mode

Field Binding


Fields all have an optional initial value. Therefore, each field can be bounded either with a value or without one. The result is 2 different ways of adding a field to a Form.

The following shows how to bind fields that has no default value.

import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.utils.ToStringIntEncoder

data class Person(val name: String, val age: Int)

val form = Form { this(
    + textField(),
    + textField(encoder = ToStringIntEncoder),
    + field<Person> { view {} },
    // ...
    onInvalid = {}) { text: String, number: Int, person: Person ->
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

This shows how to bind using initial values.

import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.utils.ToStringIntEncoder

data class Person(val name: String, val age: Int)

val form = Form { this(
    "Hello"            to textField(),
    4                  to textField(encoder = ToStringIntEncoder),
    Person("Jack", 55) to field { view {} },
    // ...
    onInvalid = {}) { text: String, number: Int, person: Person ->
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

These examples bind fields that have no names. Doodle has a labeled form control that wraps a control and assigns a name to it.

Note that a visualizer may set a field's state to some valid value at initialization time. This will give the same effect as that field having had a initial value specified that the visualizer accepted.

Forms as Fields​

Forms can also have nested forms within them. This is helpful when the field has complex data that can be presented to the user as a set of components. Such cases can be handled with custom visualizers, but many work well using a nested form.

Nested forms are created using the form builder function. It works just like the top-level Form builder, but it actually creates an instance and has access to the initial value it is bound to (if any).

import io.nacular.doodle.controls.form.form
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.utils.ToStringIntEncoder

data class Person(val name: String, val age: Int)

val form = Form { this(
       + labeled("Text"  ) { textField() },
       + labeled("Number") { textField(encoder = ToStringIntEncoder) },
       Person("Jack", 55) to form { this(
           initial.map { it.name } to labeled("Name") { textField() },
           initial.map { it.age  } to labeled("Age" ) { textField(encoder = ToStringIntEncoder) },
           onInvalid = {}
       ) { name, age ->
           Person(name, age) // construct person when valid
       } },
       // ...
       onInvalid = {}) { text: String, number: Int, person: Person ->
       // called each time all fields are updated with valid data
   }
}
Enter fullscreen mode Exit fullscreen mode

Nested forms can be used with or without initial values like any other field.

Learn more

Doodle is a pure Kotlin UI framework for the Web (and Desktop), that lets you create rich applications without relying on Javascript, HTML or CSS. Check out the documentation and tutorials to learn more.

Discussion (0)