In the last episode we introduced the ability to see form validations occurring in the client and keeping us from submitting forms that don't decode to our specified types. In the process, we introduced a bug and we ended up with ugly forms. Let's merge the functionality of elm-simple-form with the easy beauty of elm-mdl!

Project

We're starting with the time-tracker repo tagged with before_episode_022.3. Let's start off by reviewing the bug I've alluded to.

The Bug

Go ahead and create a new user. Then click the "+" to start to create another. Aaaaand there's our existing data, still in the form. This is no good. We've already fixed this for our now-defunct newUser field in the model. We did that in the CreateUserSucceeded Msg handler. Let's also reinitialize our form:

vim src/Update.elm
-- ...
updateUserMsg : UserMsg -> Model -> ( Model, Cmd Msg )
updateUserMsg msg model =
    case msg of
        -- ...
        CreateUserSucceeded _ ->
            { model
                | newUser = User Nothing ""
                , newUserForm = Form.initial [] validateNewUser
            }
                ! [ Navigation.newUrl (Route.urlFor Users) ]
-- ...

So here's what we think we'd like to do, just copying the initial newUserForm value from our initialModel function in the Model module. But of course it won't work because validateNewUser is a function in that module that's not exposed.

We could do a few things now:

  • We could expose that validateNewUser function, but let's be honest: that's not where that function belongs.
  • Knowing this, we could introduce a Validations module and expose it there. This seems reasonable and we probably should end up there. But...if you look you'll note we've duplicated two things at this point. And it's not clear from this code that this is the initial value, and if we change that in the future we'll have to remember to change it in two places. So what's our move?
  • Well...we already expose the initialModel function and it already has the values we want. Why don't we just use it, and take the values we want off of it?
-- ...
        CreateUserSucceeded _ ->
            { model
                | newUser = Model.initialModel.newUser
                , newUserForm = Model.initialModel.newUserForm
            }
                ! [ Navigation.newUrl (Route.urlFor Users) ]
-- ...

This gets us what we wanted. It's also crystal clear what we're doing from an intention point of view. Finally, it saves us from duplicating code that is in fact semantically equivalent. So let's just do that! Except....we can't. initialModel takes a Maybe Route.Location as its argument. We can just use a let to get this, but then we'll introduce that let constantly. Let's make a local function evaluated initialModel on Nothing and use that instead:

-- ...
        CreateUserSucceeded _ ->
            { model
                | newUser = initialModel.newUser
                , newUserForm = initialModel.newUserForm
            }
                ! [ Navigation.newUrl (Route.urlFor Users) ]
-- ...
{-| Just `Model.initialModel`, but applies a `Nothing` for the `Maybe
    Route.Location` argument so we can get a 0-arity version for convenience
-}
initialModel : Model
initialModel =
    Model.initialModel Nothing

With that, it behaves as we'd like and the code is far more intention-revealing. So that's got the bug fixed.

Here's that step in a single commit.

Integration with elm-mdl

Next, we'd like to get our elm-mdl inputs back. This is actually kind of interesting. We can see the implementation of the Form.Input helper functions here. That gives us a path forward.

NOTE: Before we go on, let me tell you that I did...a lot of dumb stuff before I realized it was entirely unnecessary. I won't point you directly at the commits but they're in the repo on a branch and it's hilarious to see me flail if that interests you.

Now that my self-deprecation is out of the way, our path forward is actually not bad at all. We want to ultimately produce something that extracts the value and generates the same Msg that this bit of code from elm-simple-form does, but inside of our Material.Textfield:

-- NOTE: Here the first two arguments would be `"text"` and `Text`
baseInput : String -> (String -> Field) -> Input e String
baseInput t toField state attrs =
  let
    formAttrs =
      [ type' t
      , value (state.value ?= "")
      , onInput (toField >> (Input state.path))
      , onFocus (Focus state.path)
      , onBlur (Blur state.path)
      ]
  in
    input (formAttrs ++ attrs) []

OK, so we can totally do this. Let's start off by commenting back out our elm-simple-form form and re-enabling the Material one:

vim src/View/Users/New.elm
-- don't forget to change the argument to this function back
view : Model -> Html Msg
view model =
    grid []
        [ cell [ size All 12 ]
            [ nameField model ]
        , cell [ size All 12 ]
            [ submitButton model
            , cancelButton model
            ]
        ]
-- We'll just leave this part commented out for now as a reference
-- Html.App.map (UserMsg' << NewUserFormMsg) <| viewForm newUserForm
-- viewForm : Form () User -> Html Form.Msg
-- viewForm form =
--     let
--         -- error presenter
--         errorFor field =
--             case field.liveError of
--                 Just error ->
--                     -- replace toString with your own translations
--                     div [ class "error" ] [ text (toString error) ]
--
--                 Nothing ->
--                     text ""
--
--         -- fields states
--         name =
--             Form.getFieldAsString "name" form
--     in
--         div []
--             [ label [] [ text "Name" ]
--             , Input.textInput name []
--             , errorFor name
--             , button [ onClick Form.Submit ]
--                 [ text "Submit" ]
--             ]
--

Next, we can simply modify our onInput in nameField to update the field in the form - we don't need that awkward custom SetNewUserName Msg type any more! We'll just paste in the relevant line from the baseInput function's attributes list:

-- ...
-- We ultimately don't want Input imported like it was, and we now need
-- Form.Field
import Form.Field
import Form.Input
-- ...
nameField : Model -> Html Msg
nameField model =
    -- add this let
    let
        name =
            Form.getFieldAsString "name" model.newUserForm
    in
        Textfield.render Mdl
            [ 1, 0 ]
            model.mdl
            [ Textfield.label "Name"
            , Textfield.floatingLabel
            , Textfield.text'
            , Textfield.value model.newUser.name
            --, Textfield.onInput <| UserMsg' << SetNewUserName
            , Textfield.onInput <| UserMsg' << NewUserFormMsg << (Form.Field.Text >> Form.Input name.path) -- <---
            -- from elm-form-simple: onInput (toField >> (Input state.path))
            ]

This is perhaps a bit awkward to wrap our heads around. The first bit - UserMsg' << NewUserFormMsg - is taking the place of the Html.App.map we were using in the previous example. The last bit is the expanded form of toField >> (Input.state.path) from the baseInput function. Our toField bit is just the Text constructor in their codebase, and we've spelled out Form.Input now to make sure it's clear where it comes from.

Basically, we're constructing a function that will take the string value passed to the Textfield.onInput function and mapping it into a wrapped Msg that gets where we want it to go.

That compiles. What happens if we type into it? We don't really see our typed values anymore, which makes sense because we're using the newUser field value and we're no longer updating it. Let's use the name field's value:

nameField : Model -> Html Msg
nameField model =
    let
        name =
            Form.getFieldAsString "name" model.newUserForm
    in
        Textfield.render Mdl
            [ 1, 0 ]
            model.mdl
            [ Textfield.label "Name"
            , Textfield.floatingLabel
            , Textfield.text'
              --, Textfield.value model.newUser.name
            , Textfield.value <| Maybe.withDefault "" name.value -- <---
            , Textfield.onInput <| UserMsg' << NewUserFormMsg << (Form.Field.Text >> Form.Input name.path)
            ]

Now when we type into the field, it has the text we typed. Neatish, right? But we don't have any of the validations yet which is the whole point of what we were doing. To get those to trigger we will need to introduce the Blur and Focus events. These are a bit simpler than the onInput event itself:

nameField : Model -> Html Msg
nameField model =
    let
        name =
            Form.getFieldAsString "name" model.newUserForm
    in
        Textfield.render Mdl
            [ 1, 0 ]
            model.mdl
            [ Textfield.label "Name"
            , Textfield.floatingLabel
            , Textfield.text'
            , Textfield.value <| Maybe.withDefault "" name.value
            , Textfield.onInput <| UserMsg' << NewUserFormMsg << (Form.Field.Text >> Form.Input name.path)
            , Textfield.onFocus <| UserMsg' << NewUserFormMsg <| Form.Focus name.path -- <--
            , Textfield.onBlur <| UserMsg' << NewUserFormMsg <| Form.Blur name.path -- <--
            ]

So now it should still compile, and we have all of the textfield bits wired in to use our newUserForm field on the model. What happens when we submit the form with an empty username? We don't trigger validation and we don't send the text field's value. This is because our "Submit" button is triggering the existing CreateUser Msg. What we want to do is have it trigger the Form.Submit Form.Msg:

submitButton : Model -> Html Msg
submitButton model =
    Button.render Mdl
        [ 1, 1 ]
        model.mdl
        [ Button.raised
        , Button.ripple
        , Button.colored
          --, Button.onClick <| UserMsg' CreateUser
        , Button.onClick <| UserMsg' <| NewUserFormMsg <| Form.Submit -- <--
        ]
        [ text "Submit" ]

With that we can see that it's at least no longer triggering CreateUser, obviously. But, if there is an error, we aren't showing it yet at all. Let's wire the error on our field into the textfield next:

nameField : Model -> Html Msg
nameField model =
    let
        name =
            Form.getFieldAsString "name" model.newUserForm

        -- We'll create a list of properties to add to the textfield's existing
        -- properties, conditional on the presence of an error.
        conditionalProperties =
            case name.liveError of
                -- If there's an error, show its toString for our textfield's error text
                Just error ->
                    [ Textfield.error <| toString error ]

                Nothing ->
                    [ ]
    in
        Textfield.render Mdl
            [ 1, 0 ]
            model.mdl
            ([ Textfield.label "Name"
             , Textfield.floatingLabel
             , Textfield.text'
             , Textfield.value <| Maybe.withDefault "" name.value
             , Textfield.onInput <| UserMsg' << NewUserFormMsg << (Form.Field.Text >> Form.Input name.path)
             , Textfield.onFocus <| UserMsg' << NewUserFormMsg <| Form.Focus name.path
             , Textfield.onBlur <| UserMsg' << NewUserFormMsg <| Form.Blur name.path
             ]
                ++ conditionalProperties -- <-- append them
            )

Alright, so if there's a live validation error we show it now. But of course it has awful text, so we'll introduce some basic translation for the error types:

-- ...
import Form.Error
-- ...
        conditionalProperties =
            case name.liveError of
                Just error ->
                    case error of
                        Form.Error.InvalidString ->
                            [ Textfield.error "Cannot be blank" ]

                        Form.Error.Empty ->
                            [ Textfield.error "Cannot be blank" ]

                        _ ->
                            [ Textfield.error <| toString error ]

                Nothing ->
                    []

Now if you try to submit an invalid form you get a nice error message. If you submit a valid form, it will use the code we previously had in place for the ugly version of the form's submission, and it will Just Work. With this we mostly appear to be finished and we know we no longer need the newUser field or the corresponding CreateUser or SetNewUserName messages, so let's get rid of them:

vim src/Model.elm
-- remove the
type alias Model =
    { -- ...
    , users : List User
    , newUserForm : Form () User
    -- ...
    }


initialModel : Maybe Route.Location -> Model
initialModel location =
    { -- ...
    , users = []
    , newUserForm = Form.initial [] validateNewUser
    -- ...
    }

If we try to build this now, what happens? There's an error in the urlUpdate function in Main.elm so let's go look at what we have presently:

urlUpdate : Maybe Route.Location -> Model -> ( Model, Cmd Msg )
urlUpdate location oldModel =
    let
        newModel =
            { oldModel | route = location, newUser = User Nothing "" }
    in
        newModel ! (Util.cmdsForModelRoute newModel)

Here we're setting the newUser field to the empty initial user. We've already dealt with this in the Update function previously, so we'll just duplicate that conceptually here now:

urlUpdate : Maybe Route.Location -> Model -> ( Model, Cmd Msg )
urlUpdate location oldModel =
    let
        newModel =
            { oldModel | route = location, newUserForm = (Model.initialModel Nothing).newUserForm }
    in
        newModel ! (Util.cmdsForModelRoute newModel)

This is enough to get it to compile. Next, let's remove the unnecessary Msgs:

type UserMsg
    = GotUser User
    | GotUsers (List User)
      -- | SetNewUserName String
      -- | CreateUser
    -- ...

We know we need to get rid of these two branches in the Update now:

updateUserMsg : UserMsg -> Model -> ( Model, Cmd Msg )
updateUserMsg msg model =
    case msg of
        GotUsers users ->
            { model | users = users } ! []

        -- SetNewUserName name ->
        --     let
        --         oldNewUser =
        --             model.newUser
        --     in
        --         { model | newUser = { oldNewUser | name = name } } ! []
        --
        -- CreateUser ->
        --     model ! [ API.createUser model model.newUser (UserMsg' << CreateUserFailed) (UserMsg' << CreateUserSucceeded) ]
        -- ...

If we build now we have another spot that's trying to set newUser so let's fix that:

updateUserMsg : UserMsg -> Model -> ( Model, Cmd Msg )
updateUserMsg msg model =
    case msg of
        -- ...
        CreateUserSucceeded _ ->
            { model
              -- | newUser = initialModel.newUser
                | newUserForm = initialModel.newUserForm
            }
                ! [ Navigation.newUrl (Route.urlFor Users) ]
        -- ...

With that, everything should be working.

Summary

So that's it. Today we merged elm-mdl and elm-simple-form to give us maximally awesome and attractive form fields while also letting us handle a growing model without an explosion in our Msg types. Tomorrow we'll introduce server-side validations to the mix. See you soon!

Resources