edkelly303/elm-any-type-forms/2.0.0

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. I wanted to share the work I've done so far, and I also want to try it out in some of my own projects and see if it's useful.

I would not recommend using this package in production, and you'll probably see why if you read the section about State and Delta types.

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.

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 error messages the compiler gives you when you get something wrong are awful.
  • The types of the forms it generates are complex and weird-looking.
  • The type signatures of some of the functions in the Control module are quite terrifying.
  • The implementation is very complex, undocumented, and difficult to understand.
  • There are a few known bugs - see "Known bugs and future work" at the end of this README.

Getting started

How do I build up some controls?

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

module User exposing (User, control)

import Control

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

control = 
    Control.record 
        (\name age role -> 
            { name = name
            , age = age 
            , role = role
            }
        )
        |> Control.field "Name" .name (Control.string |> Control.failIf String.isEmpty "Name is required")
        |> 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" AdminLevel (Control.int |> Control.label "Security clearance level")
        |> Control.end

How do I turn a control into a form?

Taking the example of the userControl defined in the previous section, we'll start by using Control.sandbox to turn it into a form that we can test in the browser.

Make a new file called Sandbox.elm

module Sandbox exposing (main)

import Control
import User

main = 
    Control.sandbox 
        { control = User.control
        , title = "Let's create a user!"
        , outputToString = Debug.toString
        }

If you open this Sandbox.elm file in elm reactor (or your preferred dev server - try lydell/elm-watch, you won't regret it), you'll be able to play with your form and make sure it does what you want.

How do I embed a form into a larger Elm app?

Once you're happy with your form, you'll probably want to embed it into a larger Elm app. This is where things get a bit tricky.

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 using Control.sandbox, you don't need to worry about what those types are. There's no need to define them or write type annotations.

But when you want to integrate a form into a real Elm application, your Model and Msg will need to include those State and Delta types somehow. So we need a way to work out what the types should be.

For example, let's say this Main.elm file is the entrypoint for our application:

module Main exposing (main)

import Control
import User

type Model = 
    { formState : ??? -- what `State` type do we need here?
    -- ... other `Model` fields
    }

type Msg
    = FormUpdated ??? -- what `Delta` type do we need here?
    | FormSubmitted
    | --... other `Msg` variants

1. Let elm repl tell us the types

Fortunately, the Elm compiler has our back; we can use it to tell us the types we need. In your terminal, go to your project root folder, fire up the elm repl, and ask it to tell you the type of Sandbox.main:

$ elm repl
---- Elm 0.19.1 ----------------------------------------------------------------
Say :help for help and :exit to exit! More at <https://elm-lang.org/0.19.1/repl>
--------------------------------------------------------------------------------
> import Sandbox
> Sandbox.main
<function>
    : Program
          ()
          (
          Control.State
              ( Control.State String
              , ( Control.State String
                , ( Control.State
                        ( Control.State ()
                        , ( Control.State ( Control.State String, Control.End )
                          , Control.End
                          )
                        )
                  , Control.End
                  )
                )
              )
          )
          (
          Control.Delta
              ( Control.Delta String
              , ( Control.Delta String
                , ( Control.Delta
                        ( Control.Delta ()
                        , ( Control.Delta ( Control.Delta String, Control.End )
                          , Control.End
                          )
                        )
                  , Control.End
                  )
                )
              )
          )

Yeah. Those are our types. Sorry not sorry.

2. Make some nice type aliases

Copy and paste the types from the terminal into your application to create type aliases for your form's State and Delta types:

module Main exposing (main)

import Control
import User

type alias Model = 
    { formState : FormState
    -- ... other `Model` fields
    }

type Msg
    = FormUpdated FormDelta
    | FormSubmitted
    | --... other `Msg` variants

type alias FormState = 
    Control.State
        ( Control.State String
        , ( Control.State String
          , ( Control.State
              ( Control.State ()
              , ( Control.State ( Control.State String, Control.End )
                , Control.End
                )
              )
            , Control.End
            )
          )
        )

type alias FormDelta = 
    Control.Delta
        ( Control.Delta String
        , ( Control.Delta String
          , ( Control.Delta
              ( Control.Delta ()
              , ( Control.Delta ( Control.Delta String, Control.End )
                , Control.End
                )
              )
            , Control.End
            )
          )
        )

3. Instantiate your form

Use Control.form to create an embeddable version of your form:

userForm = 
    Control.form 
        { control = User.control 
        , title = "Let's create a user!"
        , onUpdate = FormUpdated
        , onSubmit = FormSubmitted
        }

4. Plumb your form into your app's main function

Finally, integrate the form into your main function:

main : Program () Model Msg
main =
    Browser.element
        { init = 
            \() -> 
                let
                    (formState, formCmd) = 
                        userForm.init
                in
                ( { formState = formState 
                  , -- your app's other `Model` fields
                  }
                , Cmd.batch 
                    [ formCmd 
                    , -- your app's other initial Cmds
                    ]
                )
        , view = 
            \model -> 
                Html.div [] 
                    [ userForm.view model.formState
                    , -- your app's other view functions
                    ]
        , update =
            \msg model ->
                case msg of
                    FormUpdated delta ->
                        let
                            ( newFormState, formCmd ) =
                                userForm.update delta model.formState
                        in
                        ( { model | formState = newFormState }
                        , formCmd 
                        )

                    FormSubmitted ->
                        case userForm.submit model.formState of 
                            (newFormState, Ok user) ->
                                -- in a real app, you'd probably do something 
                                -- with the `user` here.
                                ( { model | formState = newFormState }
                                , Cmd.none 
                                )

                            (newFormState, Err errors) ->
                                -- in a real app, you might choose to do 
                                -- something with the `errors` here.
                                ( { model | formState = newFormState }
                                , Cmd.none 
                                )
                    
                    -- your app's other `Msg` variants

        , subscriptions = 
            \model -> 
                Sub.batch 
                    [ userForm.subscriptions model.formState
                    , -- your app's other subscriptions
                    ]
        }

How do I run the examples?

$ cd examples
$ npm install
$ . run

Known bugs and future work

  • Control.initWith doesn't send out initial Cmds for record fields or custom type variants.
  • Lack of consistency in use of labels and headings in generated HTML.
  • Lack of consistency in list indices (should lists be 0- or 1-indexed?)