Introducing dillonkearns/elm-form - a new approach to Forms in Elm

Hey everybody,

I’m excited to share a new package for parsing and rendering forms in Elm! You can check out the package docs here: dillonkearns/elm-form.

Here’s an Ellie demo that shows two forms rendered on the same page in a vanilla Elm app: https://ellie-app.com/myVVqSVC2QZa1

The Big Idea

The thesis behind this package is that a Form library can take on more responsibility for Form state and wiring if we have a cleaner separation between representing form data (unstructured String key-value pairs) and parsing form data (this part needs to know about the user’s code because it parses into the user’s preferred data types).

That means we can reduce the boilerplate down to this:

type Msg
    = FormMsg (Form.Msg Msg)
    -- | ... Other Msg's for your app

type alias Model =
    { formModel : Form.Model
    , submitting : Bool
    -- , ... additional state for your app
    }

init : Flags -> ( Model, Cmd Msg )
init flags =
    ( { formModel = Form.init
      , submitting = False
      }
    , Cmd.none
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        OnSubmit { parsed } ->
            case parsed of
                Form.Valid signUpData ->
                    ( { model | submitting = True }
                    , sendSignUpData signUpData )
                Form.Invalid _ _ ->
                    -- validation errors are displayed already so
                    -- we don't need to do anything else here if we don't want to
                    ( model, Cmd.none )

        FormMsg formMsg ->
            let
                ( updatedFormModel, cmd ) =
                    Form.update formMsg model.formModel
            in
            ( { model | formModel = updatedFormModel }, cmd )


formView : Model -> Html Msg
formView model =
    signUpForm
        |> Form.renderHtml
        { submitting = model.submitting
        , state = model.formModel
        , toMsg = FormMsg
        }
        (Form.options "signUpForm"
            |> Form.withOnSubmit OnSubmit
        )
        []

The Form.Model type stores a Dict of state, so we don’t need to change our Model or Msg to manage additional form state (you can see an example of that in the Ellie demo).

Defining the Form

Here’s an example of a Form definition. Note that we get client-side validations from these definitions, including a check that depends on other form fields in the case of the password confirmation field in this example.

type alias SignUpForm =
    { username : String, password : String }


signUpForm : Form.HtmlForm String SignUpForm input
signUpForm =
    (\username password passwordConfirmation ->
        { combine =
            Validation.succeed SignUpForm
                |> Validation.andMap username
                |> Validation.andMap
                    (Validation.map2
                        (\passwordValue passwordConfirmationValue ->
                            if passwordValue == passwordConfirmationValue then
                                Validation.succeed passwordValue

                            else
                                Validation.fail "Must match password" passwordConfirmation
                        )
                        password
                        passwordConfirmation
                        |> Validation.andThen identity
                    )
        , view =
            \formState ->
                let
                    fieldView label field =
                        Html.div []
                            [ Html.label []
                                [ Html.text (label ++ " ")
                                , FieldView.input [] field
                                , errorsView formState field
                                ]
                            ]
                in
                [ fieldView "username" username
                , fieldView "Password" password
                , fieldView "Password Confirmation" passwordConfirmation
                , if formState.submitting then
                    Html.button
                        [ Html.Attributes.disabled True ]
                        [ Html.text "Signing Up..." ]

                  else
                    Html.button [] [ Html.text "Sign Up" ]
                ]
        }
    )
        |> Form.form
        |> Form.field "username" (Field.text |> Field.required "Required")
        |> Form.field "password" (Field.text |> Field.password |> Field.required "Required")
        |> Form.field "password-confirmation" (Field.text |> Field.password |> Field.required "Required")

Serializing Data

You can take this idea a step further if you use code sharing to re-use your Form on an Elm backend. This package was originally the Form API in the elm-pages v3 beta, but I got some requests to make it available as a standalone package. But if you have the ability to do full-stack Elm, as with the elm-pages v3 beta or with Lamdera, you can even use a single OnSubmit Msg to send the raw form data (List ( String, String)), then parse that on the backend using the same Form definitions.

Delegating to Your Framework

You can reduce the boilerplate even further if you move some of the responsibility into your framework (whether it’s a published tool or an internal framework). elm-pages manages form submissions automatically using the idea of progressive enhancement to emulate the built-in Browser behavior when you submit a form. This means that the framework keeps track of which form is submitting so you don’t need to wire that up every time you create a new form. It even uses a single application-level Msg to let the framework perform form submissions through its top-level update function so you don’t need to define that logic for every form or every page in your app.

I’d love to hear what people build with this new package. Feedback is very welcome. Hope it’s useful!

28 Likes

This is amazing. And perfect timing for one of our projects. We’ll take a look at using it and let you know how it goes. :slight_smile: .

2 Likes

I really like this library! I’ve been playing with it on and off this past week. One question that I have is what approach do you take to test the forms that you create?

I had a play today to see if I could get ProgramTest style tests working with this but I hit a couple of hurdles.

The first hurdle comes from the way events are handled within the form which means that using ProgramTest’s fillIn, clickButton etc. don’t work. ProgramTest expects simple event handlers to be added to the specific elements e.g the input/button and also don’t do anything fancy with data on the event where as elm-form attaches event handlers to the form and uses the event object to get field data. It’s possible to work around this by using simulateDomEvent but it results in fairly brittle tests that are closely tied to the event data structure elm-form expects.

The second hurdle I found came from the fact that elm-form returns Cmd’s when calling update. The ProgramTest approach depends on being able to know what a given effect value represents and subsequently avoids using Cmd’s directly because they are opaque. I may be wrong but I think this stopped me from being able to simulate progressing the form state.

After exploring I thought it would be good to take a step back and ask, how do you go about testing your forms?

2 Likes

I’ll be interested to see how elm-program-test works with Elm Land if/when this form framework is integrated since it does have the concept of an Effect built-in. I suppose one doesn’t need to rely on formal integration, since any Elm Land-generated app can presumably add this form framework and integrate it with its Effect module and have elm-program-test working. Exciting times in Elm-world.

1 Like

Hey @RGBboy, yeah this is a great discussion. You’re right that the elm-program-test helpers like fillIn make some assumptions about simpler event handlers. Essentially, as you say, elm-program-test’s fillIn and similar helpers currently assume that onInput or other simple event handlers are being used, whereas elm-form registers its event handlers differently in a way that elm-program-test doesn’t currently simulate.

Like you said, simulateDomEvent can be used. Here’s an Ellie demo that shows a fairly basic version of fillIn that works with dillonkearns/elm-form: https://ellie-app.com/mF2ffzCHCvSa1.

I think we could get it to be fairly robust with a little polishing. The helper looks for a field with a given form id and field name attribute. The more robust version would:

  • Take more high-level input for uniquely selecting the field to input into (maybe the label text and form ID?)
  • Look for the label using different valid approaches (similar to how fillIn does)
  • Give an error if the label is non-unique or non-existent, or if it doesn’t have the expected input type
  • Otherwise, simulate the appropriate DOM event

Down the road, it could be nice to see if Aaron is up for a contribution to support that directly in elm-program-test, but I think a good first step would be to build a nice helper outside of it since all of the building blocks needed for that are exposed. It could possibly be turned into a module of test helpers in the dillonkearns/elm-form package as well, though again a good first step would be to iterate on that in Ellie’s and discuss the ideal design.

I think that the Discussions on the elm-form GitHub would be a good place to explore more: dillonkearns/elm-form · Discussions · GitHub. Maybe we can get more conversation on the topic going over there?

I have more thoughts on testing as well, but I’ll leave it to the high-level for now. Of course things like Cypress are sometimes helpful for testing with these forms, but there’s not much to say about that because they just work as the browser would normally. It is also possible to run tests using the Form parser with different kinds of values and make assertions about what they decode to (as the tests in this folder do https://github.com/dillonkearns/elm-form/tree/main/tests). It could possibly make sense to provide an API with some helpers for making assertions on that as well, or maybe the API as is is sufficient for testing. I think that would be a great thing to discuss more on GitHub as well.

Thanks again for bringing the topic up!

Yeah, as you say I wouldn’t expect elm-land to do a formal integration, but I would imagine you could integrate it assuming that it’s similar to elm-spa in the way that it lets you customize your Main.elm.

Happy to answer questions if you try integrating it. There’s an #elm-form channel on the Elm Slack, feel free to ping me there if you want any help with that!

There is one additional challenge with elm-program-test.

In order to simplify the wiring, I use Cmd’s in the update function to send Msg’s.

That lets you wire it up like this:

For elm-program-test, you would want those to be Effect’s instead.

One approach I’ve considered there is providing both a simple update (like the current implementation) as well as an advanced update that doesn’t return a Cmd:

update : Msg msg -> Model -> ( Model, Cmd msg )

updateAdvanced : Msg msg -> Model -> ( Model, Maybe msg )

I’m open to ideas on which strategy to go with there! I believe that design would work for elm-program-test.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.