edkelly303/elm-any-type-forms/1.1.1

NOTE: the latest version is 8.1.0

Any-type forms

DISCLAIMER

I am uneasy about publishing this package in its current rough and unfinished state. However, impending fatherhood means I won't be doing much programming over the next few months, and I wanted to share the work I've done so far.

I would not recommend using this package in production, and you'll probably see why if you read to the end of this document.

Nevertheless, I think the techniques I used to implement control combinators with nested tuples may be of interest to people who like using Elm to do weird, unElmish things.

Perhaps by the time our baby lets me get back to coding again, someone will have been inspired to build something less ridiculous and more useful with nested tuples.

What does it do?

This package allows you to build up user input forms that you can use to create or update any arbitrary Elm type. Forms are composed from individual controls, and the internal state of each control can be any arbitrary Elm type.

This gives the user a lot of flexibility to create whatever controls they like, as well as making it easy to create forms for complex data structures by combining simpler controls, without too much wiring or boilerplate.

What's in the package?

The package provides basic controls that produce primitive Elm types, such as String, Int, Float, etc. If you need a completely custom control, you can easily create one from scratch using Control.create.

It also provides combinators that allow the user to combine controls to create more complex types. In addition to standard combinators for List, Dict, Maybe, Tuple and Wrapper types, you can also create combinators for records and custom types, with an API similar to miniBill/elm-codec.

Finally, it provides functions to validate and debounce controls, without worrying about any annoying state management or book-keeping.

What's nice about it?

  • Simple(?), composable API
  • Custom controls with whatever types you like
  • Less wiring & boilerplate
  • Built-in validation (including multi-field validation)
  • Built-in debouncing

What's nasty about it?

  • The default controls don't look very nice (this is fixable, just needs some CSS).
  • The types of the forms it generates are a bit unintuitive (see "How do I include a form in my Model and Msg types?").
  • The type signatures of some of the functions in the Control module are... interesting.
  • The implementation is pretty difficult to understand.

How do I build up some controls?

Here's an example that shows the record and customType combinators in action:

type alias User = 
    { name : String
    , age : Int
    , role : Role
    }

userControl = 
    Control.record 
        (\name age role -> 
            { name = name
            , age = age 
            , role = role
            }
        )
        |> Control.field "Name" .name Control.string
        |> Control.field "Age" .age Control.int
        |> Control.field "Role" .role roleControl
        |> Control.end

type Role
    = Regular
    | AdminLevel Int

roleControl = 
    Control.customType
        (\regular adminLevel tag ->
            case tag of
                Regular -> 
                    regular
                AdminLevel level ->
                    adminLevel level
        )
        |> Control.tag0 "Regular" Regular
        |> Control.tag1 "Admin Level" AdminLevel Control.int
        |> Control.end

How do I turn a control into a form and wire it into my Elm app?

The big tradeoff of this package is that its forms build up quite large and complex State and Delta types (which are the equivalent of an Elm program's Model and Msg types, respectively).

When you want to hook up a form in your Elm application, your Model and Msg will need to include those State and Delta types - which means you have to be able to work out what they are.

Fortunately, the Elm compiler has our back here, and we can use it to tell us what types we need. We'll give it some deliberately incorrect type annotations, and see what error messages it gives us.

type alias Model = 
    { state : () } -- we'll get an error here, because the form's `state` won't be `()`


type Msg 
    = FormUpdated () -- we'll get an error here, because the form's `delta` won't be `()`
    | FormSubmitted


userForm =
    Control.toForm
        "Let's make a User"
        FormUpdated
        FormSubmitted
        userControl


main : Program () Model Msg
main =
    Browser.element
        { init = \() -> ( { state = userForm.init }, Cmd.none )
        , view = \model -> userForm.view model.state
        , update =
            \msg model ->
                case msg of
                    FormUpdated delta ->
                        let
                            ( state, cmd ) =
                                userForm.update delta model.state
                        in
                        ( { model | state = state }, cmd )

                    _ ->
                        ( model, Cmd.none )
        , subscriptions = \_ -> Sub.none
        }

When you compile this, you should get some errors like this:

-- TYPE MISMATCH ----------------------------------------------

Something is off with the body of the `main` definition:

    Browser.element
        { init = \() -> ( { state = myForm.init }, Cmd.none )
        , view = \model -> myForm.view model.state
        , update =
            \msg model ->
                case msg of
                    FormUpdated delta ->
                        let
                            ( state, cmd ) =
                                myForm.update delta model.state
                        in
                        ( { model | state = state }, cmd )

                    _ ->
                        ( model, Cmd.none )
        , subscriptions = \_ -> Sub.none
        }

This `element` call produces:

    Program
        ()
        { state :
              Control.State
                  ( Control.State String
                  , ( Control.State String
                    , ( Control.State
                            ( Control.State ()
                            , ( Control.State
                                    ( Control.State String, Control.End )
                              , Control.End
                              )
                            )
                      , Control.End
                      )
                    )
                  )
        }
        Msg

But the type annotation on `main` says it should be:

    Program () Model Msg

-- TYPE MISMATCH ----------------------------------------------

The 1st argument to this function is not what I expect:

                                myForm.update delta model.state
                                              ^^^^^
This `delta` value is a:

    ()

But this function needs the 1st argument to be:

    Control.Delta
        ( Control.Delta String
        , ( Control.Delta String
          , ( Control.Delta
                  ( Control.Delta ()
                  , ( Control.Delta ( Control.Delta String, Control.End )
                    , Control.End
                    )
                  )
            , Control.End
            )
          )
        )

So, your types are going to be:

type alias Model = 
    { state : Control.State
                  ( Control.State String
                  , ( Control.State String
                    , ( Control.State
                            ( Control.State ()
                            , ( Control.State
                                    ( Control.State String, Control.End )
                              , Control.End
                              )
                            )
                      , Control.End
                      )
                    )
                  ) 
    }


type Msg 
    = FormUpdated 
        (  Control.Delta
            ( Control.Delta String
            , ( Control.Delta String
            , ( Control.Delta
                    ( Control.Delta ()
                    , ( Control.Delta ( Control.Delta String, Control.End )
                        , Control.End
                        )
                    )
                , Control.End
                )
            )
        ))
    | FormSubmitted

Yeah. Do you hate it yet?

How do I run the examples?

$ cd examples
$ npm install
$ . run