DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on

Diary of an Elm Developer - From fields to forms

When Dillon introduced his form library he shared an Ellie demo containing a sign up form and a check in form in order to illustrate the capabilities of his library. The forms featured validation on blur, validations that depend on multiple form fields and the ability to reuse your form definitions.

I tried to recreate his demo starting from my Field type and discovered several beautiful (to me at least) abstractions along the way, Form and InteractionTracker.

Let's start by implementing the sign up form.

Sign Up form

Initialize

The sign up form consists of four form fields: username, password, password confirmation, and role.

type alias Fields =
    { username : Field Username
    , password : Field Password
    , passwordConfirmation : Field PasswordConfirmation
    , role : Field Role
    }
Enter fullscreen mode Exit fullscreen mode

The username field is initialized to the string "dillon", the role field is initializied to the role SuperAdmin and the password fields are left empty.

init : Fields
init =
    { username = F.fromString Username.fieldType "dillon"
    , password = F.empty Password.fieldType
    , passwordConfirmation = F.empty PasswordConfirmation.fieldType
    , role = F.fromValue Role.fieldType Role.SuperAdmin
    }
Enter fullscreen mode Exit fullscreen mode

Update

We need to control how the fields are set so that we can enforce the rules of the form. We do it by providing functions to set the fields.

type alias Setters =
    { setUsername : String -> Fields -> Fields
    , setPassword : String -> Fields -> Fields
    , setPasswordConfirmation : String -> Fields -> Fields
    , setRole : Role -> Fields -> Fields
    }

setters : Setters
setters =
    { setUsername =
        \s fields ->
            { fields | username = F.setFromString s fields.username }
    , setPassword =
        \s fields ->
            let
                password =
                    F.setFromString s fields.password
            in
            { fields | password = password, passwordConfirmation = updatePasswordConfirmation password fields.passwordConfirmation }
    , setPasswordConfirmation =
        \s fields ->
            let
                passwordConfirmation =
                    F.setFromString s fields.passwordConfirmation
            in
            { fields | passwordConfirmation = updatePasswordConfirmation fields.password passwordConfirmation }
    , setRole =
        \role fields ->
            { fields | role = F.setFromValue role fields.role }
    }

updatePasswordConfirmation : Field Password -> Field PasswordConfirmation -> Field PasswordConfirmation
updatePasswordConfirmation passwordField passwordConfirmationField =
    (\password passwordConfirmation ->
        if Password.toString password == PasswordConfirmation.toString passwordConfirmation then
            passwordConfirmationField

        else
            F.setError "The password confirmation does not match." passwordConfirmationField
    )
        |> F.get passwordField
        |> F.and passwordConfirmationField
        |> F.withDefault passwordConfirmationField
Enter fullscreen mode Exit fullscreen mode

All the fields validate their own values like before, see Diary of an Elm Developer - Exploring an API for form fields, but notice how the password confirmation matching validation is handled. When you set the password or the password confirmation we check to see whether or not they match. If they don't match we set a validation error.

In this example, custom field errors are of type String but you have the freedom to use any error type you desire. For e.g. in another example not shown here I used F.setError PasswordConfirmation.Mismatch passwordConfirmationField.

Validate

Finally, we need to say how to validate the form and parse the user's input into an output data structure of our choosing.

type Error
    = UsernameError F.Error
    | PasswordError F.Error
    | PasswordConfirmationError F.Error
    | RoleError F.Error


type alias Output =
    { username : Username
    , password : Password
    , role : Role
    }

validate : Fields -> Validation Error Output
validate fields =
    (\username password _ role ->
        Output username password role
    )
        |> F.get (fields.username |> F.mapError UsernameError)
        |> F.and (fields.password |> F.mapError PasswordError)
        |> F.and (fields.passwordConfirmation |> F.mapError PasswordConfirmationError)
        |> F.and (fields.role |> F.mapError RoleError)
Enter fullscreen mode Exit fullscreen mode

Putting it all together

module Dillon.SignUp exposing
    ( Error
    , Fields
    , Output
    , Setters
    , SignUp
    , form
    )

type alias SignUp =
    Form Fields Setters Error Output

form : SignUp
form =
    Form.new
        { init = init
        , setters = setters
        , validate = validate
        }
Enter fullscreen mode Exit fullscreen mode

Let's take a step back to understand what we've achieved. All the requirements of the sign up form has been encapsulated into a reusable module, Dillon.SignUp, such that there is no way for a consumer of our form module to mess up the requirements of the form. At the same time anyone using the module is free to render the form and provide whatever UX they deem necessary.

If you don't believe me then let me show you by building a simple view and then extending it to work just like Dillon's sign up form demo.

A simple view of the Sign Up form

Model

type alias Model =
    { signUp : SignUp
    , maybeOutput : Maybe SignUp.Output
    }

init : Model
init =
    { signUp = SignUp.form
    , maybeOutput = Nothing
    }
Enter fullscreen mode Exit fullscreen mode

Update

type Msg
    = InputUsername String
    | InputPassword String
    | InputPasswordConfirmation String
    | InputRole Role
    | Submit

update : Msg -> Model -> Model
update msg model =
    case msg of
        InputUsername s ->
            { model | signUp = Form.update .setUsername s model.signUp }

        InputPassword s ->
            { model | signUp = Form.update .setPassword s model.signUp }

        InputPasswordConfirmation s ->
            { model | signUp = Form.update .setPasswordConfirmation s model.signUp }

        InputRole role ->
            { model | signUp = Form.update .setRole role model.signUp }

        Submit ->
            { model | maybeOutput = Form.validateAsMaybe model.signUp }
Enter fullscreen mode Exit fullscreen mode

View

view : Model -> H.Html Msg
view { signUp, maybeOutput } =
    let
        fields =
            Form.toFields signUp
    in
    H.div []
        [ H.h2 [] [ H.text "Sign Up" ]
        , H.form
            [ HA.novalidate True
            , HE.onSubmit Submit
            ]
            [ viewInput
                { id = "username"
                , label = "Username"
                , type_ = "text"
                , field = fields.username
                , errorToString = Username.errorToString
                , isRequired = True
                , isDisabled = False
                , attrs = [ HA.autofocus True ]
                , onInput = InputUsername
                }
            , viewInput
                { id = "password"
                , label = "Password"
                , type_ = "password"
                , field = fields.password
                , errorToString = Password.errorToString
                , isRequired = True
                , isDisabled = False
                , attrs = []
                , onInput = InputPassword
                }
            , viewInput
                { id = "passwordConfirmation"
                , label = "Password Confirmation"
                , type_ = "password"
                , field = fields.passwordConfirmation
                , errorToString = PasswordConfirmation.errorToString
                , isRequired = True
                , isDisabled = False
                , attrs = []
                , onInput = InputPasswordConfirmation
                }
            , viewSelect
                { id = "role"
                , label = "Role"
                , field = fields.role
                , options =
                    case F.toMaybe fields.role of
                        Just role ->
                            Selection.select role defaultRoleOptions

                        Nothing ->
                            defaultRoleOptions
                , toOption = roleToString
                , errorToString = Role.errorToString
                , isRequired = True
                , isDisabled = False
                , onInput = InputRole
                }
            , H.p []
                [ H.button
                    [ HA.disabled <| Form.isInvalid signUp ]
                    [ H.text "Sign Up" ]
                ]
            ]
        , case maybeOutput of
            Just { username, password, role } ->
                H.div []
                    [ H.h2 [] [ H.text "Output" ]
                    , H.p [] [ H.text <| "Username: " ++ Username.toString username ]
                    , H.p [] [ H.text <| "Password: " ++ Password.toString password ]
                    , H.p [] [ H.text <| "Role: " ++ roleToString role ]
                    ]

            Nothing ->
                H.text ""
        ]

defaultRoleOptions : Selection Role
defaultRoleOptions =
    Selection.fromList [] Role.Admin [ Role.SuperAdmin, Role.Regular ]

roleToString : Role -> String
roleToString role =
    case role of
        Role.Regular ->
            "Regular"

        Role.Admin ->
            "Admin"

        Role.SuperAdmin ->
            "Super Admin"
Enter fullscreen mode Exit fullscreen mode

That's it. The form abstraction fits quite nicely into TEA such that the view code you write remains very similar to what you would have written without the abstractions.

But, that's a simple view. What about adding blur on validation? Well, let's see.

A complex view of the Sign Up form

Blur on validation is a UI concern so the changes SHOULD show up in the UI part of the code. If they show up anywhere else then something is wrong with the abstraction.

Model

type alias Model =
    { signUp : SignUp
    , usernameTracker : InteractionTracker.State
    , passwordTracker : InteractionTracker.State
    , passwordConfirmationTracker : InteractionTracker.State
    , timer : Timer
    , isSubmitting : Bool
    , maybeOutput : Maybe SignUp.Output
    }


init : () -> ( Model, Cmd msg )
init _ =
    ( { signUp = SignUp.form
      , usernameTracker = InteractionTracker.init
      , passwordTracker = InteractionTracker.init
      , passwordConfirmationTracker = InteractionTracker.init
      , timer = Timer.init
      , isSubmitting = False
      , maybeOutput = Nothing
      }
    , Cmd.none
    )


timerConfig : Timer.Config Msg
timerConfig =
    Timer.config
        { wait = 5000
        , onExpire = ExpiredTimer
        , onChange = ChangedTimer
        }
Enter fullscreen mode Exit fullscreen mode

I added five new record fields to the model.

usernameTracker, passwordTracker, and passwordConfirmationTracker keeps track of the UI state (Not visited, Focused, Changed, and Blurred) of the corresponding form fields.

timer and isSubmitting are used to simulate submitting a form to a backend that takes some time, about 5 seconds.

Update

We have a few new messages to handle.

type Msg
    -- The old messages remain the same
    = InputUsername String
    | InputPassword String
    | InputPasswordConfirmation String
    | InputRole Role
    | Submit
    -- The new messages
    | ChangedUsernameTracker (InteractionTracker.Msg Msg)
    | ChangedPasswordTracker (InteractionTracker.Msg Msg)
    | ChangedPasswordConfirmationTracker (InteractionTracker.Msg Msg)
    | ExpiredTimer
    | ChangedTimer Timer.Msg
Enter fullscreen mode Exit fullscreen mode

Except for Submit the old message implementations in update remain the same.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- The old messages remain the same except for Submit
        -- ...

        Submit ->
            let
                ( timer, cmd ) =
                    Timer.setTimeout timerConfig model.timer
            in
            ( { model | timer = timer, isSubmitting = True }
            , cmd
            )

        -- The new messages

        ChangedUsernameTracker subMsg ->
            let
                ( usernameTracker, cmd ) =
                    InteractionTracker.update subMsg model.usernameTracker
            in
            ( { model | usernameTracker = usernameTracker }
            , cmd
            )

        ChangedPasswordTracker subMsg ->
            let
                ( passwordTracker, cmd ) =
                    InteractionTracker.update subMsg model.passwordTracker
            in
            ( { model | passwordTracker = passwordTracker }
            , cmd
            )

        ChangedPasswordConfirmationTracker subMsg ->
            let
                ( passwordConfirmationTracker, cmd ) =
                    InteractionTracker.update subMsg model.passwordConfirmationTracker
            in
            ( { model | passwordConfirmationTracker = passwordConfirmationTracker }
            , cmd
            )

        ExpiredTimer ->
            ( { model
                | signUp = SignUp.form
                , usernameTracker = InteractionTracker.init
                , passwordTracker = InteractionTracker.init
                , passwordConfirmationTracker = InteractionTracker.init
                , timer = Timer.init
                , isSubmitting = False
                , maybeOutput = Form.validateAsMaybe model.signUp
              }
            , Cmd.none
            )

        ChangedTimer subMsg ->
            ( model
            , Timer.update timerConfig subMsg model.timer
            )
Enter fullscreen mode Exit fullscreen mode

View

We have replaced viewInput with viewInteractiveInput which takes two extra fields: tracker and onChange. Everything else remains the same.

view : Model -> H.Html Msg
view { signUp, usernameTracker, passwordTracker, passwordConfirmationTracker, timer, isSubmitting, maybeOutput } =
    let
        fields =
            Form.toFields signUp
    in
    H.div []
        [ H.h2 [] [ H.text "Sign Up" ]
        , H.form
            [ HA.novalidate True
            , HE.onSubmit Submit
            ]
            [ viewInteractiveInput
                { id = "username"
                , label = "Username"
                , type_ = "text"
                , field = fields.username
                , tracker = usernameTracker
                , errorToString = Username.errorToString
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs = [ HA.autofocus True ]
                , onInput = InputUsername
                , onChange = ChangedUsernameTracker
                }
            , viewInteractiveInput
                { id = "password"
                , label = "Password"
                , type_ = "password"
                , field = fields.password
                , tracker = passwordTracker
                , errorToString = Password.errorToString
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs = []
                , onInput = InputPassword
                , onChange = ChangedPasswordTracker
                }
            , viewInteractiveInput
                { id = "passwordConfirmation"
                , label = "Password Confirmation"
                , type_ = "password"
                , field = fields.passwordConfirmation
                , tracker = passwordConfirmationTracker
                , errorToString = PasswordConfirmation.errorToString
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs = []
                , onInput = InputPasswordConfirmation
                , onChange = ChangedPasswordConfirmationTracker
                }
            , viewSelect
                { id = "role"
                , label = "Role"
                , field = fields.role
                , options =
                    case F.toMaybe fields.role of
                        Just role ->
                            Selection.select role defaultRoleOptions

                        Nothing ->
                            defaultRoleOptions
                , toOption = roleToString
                , errorToString = Role.errorToString
                , isRequired = True
                , isDisabled = isSubmitting
                , onInput = InputRole
                }
            , H.p []
                [ H.button
                    [ HA.disabled (Form.isInvalid signUp || isSubmitting) ]
                    [ H.text <|
                        if isSubmitting then
                            "Signing Up..."

                        else
                            "Sign Up"
                    ]
                ]
            ]
        , case maybeOutput of
            Just { username, password, role } ->
                H.div []
                    [ H.h2 [] [ H.text "Output" ]
                    , H.p [] [ H.text <| "Username: " ++ Username.toString username ]
                    , H.p [] [ H.text <| "Password: " ++ Password.toString password ]
                    , H.p [] [ H.text <| "Role: " ++ roleToString role ]
                    ]

            Nothing ->
                H.text ""
        ]
Enter fullscreen mode Exit fullscreen mode

A few points to note

  • We didn't have to touch Dillon.SignUp at all. It's truly reusable.
  • The UI is an orthogonal concern and changes to the UI led to corrresponding changes in the view code as we had hoped.
  • A moderate increase in complexity in the UI led to a moderate increase in complexity in the view code.

These things seem significant and promising from my point of view since they were sources of contention anytime I had to write Elm forms in the past.

Let's move on to the check in form.

Check In form

Initialize

type alias Fields =
    { name : Field String
    , checkIn : Field Date
    , checkInTime : Field Time
    , checkOut : Field Date
    , subscribe : Field Bool
    }

init : Fields
init =
    { name = F.fromString F.nonBlankString "dillon"
    , checkIn = F.empty Date.fieldType
    , checkInTime = F.empty Time.fieldType
    , checkOut = F.empty Date.fieldType
    , subscribe = F.fromValue F.bool True
    }
Enter fullscreen mode Exit fullscreen mode

I use the String type for name but I validate it as a non-blank string such that the empty string and strings of whitespace are not valid values for the field. However, I created custom types for Date and Time.

Update

type alias Setters =
    { setName : String -> Fields -> Fields
    , setCheckIn : { today : Date, s : String } -> Fields -> Fields
    , setCheckInTime : String -> Fields -> Fields
    , setCheckOut : String -> Fields -> Fields
    , setSubscribe : Bool -> Fields -> Fields
    }
Enter fullscreen mode Exit fullscreen mode

Notice how setting the check in date depends on today's date and it's explicit in the type.

setters : Setters
setters =
    { setName =
        \s fields ->
            { fields | name = F.setFromString s fields.name }
    , setCheckIn =
        \{ today, s } fields ->
            --
            -- N.B. You can pass in whatever data you need from the outside via a tuple or record.
            --
            let
                checkIn =
                    F.setFromString s fields.checkIn
            in
            case F.toMaybe checkIn of
                Just date ->
                    if date |> Date.isAfter today then
                        { fields | checkIn = checkIn }

                    else
                        { fields | checkIn = F.setError "You must check in after today." checkIn }

                Nothing ->
                    { fields | checkIn = checkIn }
    , setCheckInTime =
        \s fields ->
            let
                checkInTime =
                    F.setFromString s fields.checkInTime
            in
            case F.toMaybe checkInTime of
                Just time ->
                    if time |> Time.isBetween Time.nineAm Time.fivePm then
                        { fields | checkInTime = checkInTime }

                    else
                        { fields | checkInTime = F.setError "You must check in between the hours of 9am to 5pm." checkInTime }

                Nothing ->
                    { fields | checkInTime = checkInTime }
    , setCheckOut =
        \s fields ->
            let
                checkOut =
                    F.setFromString s fields.checkOut

                maybeCheckOutIsAfterCheckIn =
                    Date.isAfter
                        |> F.get fields.checkIn
                        |> F.and checkOut
                        |> F.andMaybe
            in
            case maybeCheckOutIsAfterCheckIn of
                Just checkOutIsAfterCheckIn ->
                    if checkOutIsAfterCheckIn then
                        { fields | checkOut = checkOut }

                    else
                        { fields | checkOut = F.setError "You must check out after you've checked in." checkOut }

                Nothing ->
                    { fields | checkOut = checkOut }
    , setSubscribe =
        \b fields ->
            { fields | subscribe = F.setFromValue b fields.subscribe }
    }
Enter fullscreen mode Exit fullscreen mode

Take note of how the validation dependencies are handled.

Validate

type Error
    = NameError F.Error
    | CheckInError F.Error
    | CheckInTimeError F.Error
    | CheckOutError F.Error
    | SubscribeError F.Error

type alias Output =
    { name : String
    , stay : Stay
    , isSubscribed : Bool
    }

type alias Stay =
    { date : Date
    , time : Time
    , nights : Int
    }

validate : Fields -> Validation Error Output
validate fields =
    (\name checkIn checkInTime checkOut subscribe ->
        Output
            name
            (Stay checkIn checkInTime (Date.nights checkIn checkOut))
            subscribe
    )
        |> F.get (fields.name |> F.mapError NameError)
        |> F.and (fields.checkIn |> F.mapError CheckInError)
        |> F.and (fields.checkInTime |> F.mapError CheckInTimeError)
        |> F.and (fields.checkOut |> F.mapError CheckOutError)
        |> F.and (fields.subscribe |> F.mapError SubscribeError)
Enter fullscreen mode Exit fullscreen mode

Putting it all together

module Dillon.CheckIn exposing
    ( CheckIn
    , Error
    , Fields
    , Output
    , Setters
    , form
    )

type alias CheckIn =
    Form Fields Setters Error Output

form : CheckIn
form =
    Form.new
        { init = init
        , setters = setters
        , validate = validate
        }
Enter fullscreen mode Exit fullscreen mode

A view of the Check In form

Model

type alias Model =
    { today : Date
    , checkIn : CheckIn
    , nameTracker : InteractionTracker.State
    , checkInTracker : InteractionTracker.State
    , checkInTimeTracker : InteractionTracker.State
    , checkOutTracker : InteractionTracker.State
    , subscribeTracker : InteractionTracker.State
    , timer : Timer
    , isSubmitting : Bool
    , maybeOutput : Maybe CheckIn.Output
    }


init : () -> ( Model, Cmd Msg )
init _ =
    ( { today = Date.jul1st2025
      , checkIn = CheckIn.form
      , nameTracker = InteractionTracker.init
      , checkInTracker = InteractionTracker.init
      , checkInTimeTracker = InteractionTracker.init
      , checkOutTracker = InteractionTracker.init
      , subscribeTracker = InteractionTracker.init
      , timer = Timer.init
      , isSubmitting = False
      , maybeOutput = Nothing
      }
    , Date.today GotDate
    )


timerConfig : Timer.Config Msg
timerConfig =
    Timer.config
        { wait = 5000
        , onExpire = ExpiredTimer
        , onChange = ChangedTimer
        }
Enter fullscreen mode Exit fullscreen mode

Update

type Msg
    = GotDate Date
    | InputName String
    | ChangedNameTracker (InteractionTracker.Msg Msg)
    | InputCheckIn String
    | ChangedCheckInTracker (InteractionTracker.Msg Msg)
    | InputCheckInTime String
    | ChangedCheckInTimeTracker (InteractionTracker.Msg Msg)
    | InputCheckOut String
    | ChangedCheckOutTracker (InteractionTracker.Msg Msg)
    | ToggleSubscribe
    | ChangedSubscribeTracker (InteractionTracker.Msg Msg)
    | Submit
    | ExpiredTimer
    | ChangedTimer Timer.Msg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotDate date ->
            ( { model | today = date }
            , Cmd.none
            )

        InputName s ->
            ( { model | checkIn = Form.update .setName s model.checkIn }
            , Cmd.none
            )

        ChangedNameTracker subMsg ->
            let
                ( nameTracker, cmd ) =
                    InteractionTracker.update subMsg model.nameTracker
            in
            ( { model | nameTracker = nameTracker }
            , cmd
            )

        InputCheckIn s ->
            ( { model | checkIn = Form.update .setCheckIn { today = model.today, s = s } model.checkIn }
            , Cmd.none
            )

        ChangedCheckInTracker subMsg ->
            let
                ( checkInTracker, cmd ) =
                    InteractionTracker.update subMsg model.checkInTracker
            in
            ( { model | checkInTracker = checkInTracker }
            , cmd
            )

        InputCheckInTime s ->
            ( { model | checkIn = Form.update .setCheckInTime s model.checkIn }
            , Cmd.none
            )

        ChangedCheckInTimeTracker subMsg ->
            let
                ( checkInTimeTracker, cmd ) =
                    InteractionTracker.update subMsg model.checkInTimeTracker
            in
            ( { model | checkInTimeTracker = checkInTimeTracker }
            , cmd
            )

        InputCheckOut s ->
            ( { model | checkIn = Form.update .setCheckOut s model.checkIn }
            , Cmd.none
            )

        ChangedCheckOutTracker subMsg ->
            let
                ( checkOutTracker, cmd ) =
                    InteractionTracker.update subMsg model.checkOutTracker
            in
            ( { model | checkOutTracker = checkOutTracker }
            , cmd
            )

        ToggleSubscribe ->
            let
                { subscribe } =
                    Form.toFields model.checkIn

                b =
                    subscribe
                        |> F.toMaybe
                        |> Maybe.map not
                        |> Maybe.withDefault False
            in
            ( { model | checkIn = Form.update .setSubscribe b model.checkIn }
            , Cmd.none
            )

        ChangedSubscribeTracker subMsg ->
            let
                ( subscribeTracker, cmd ) =
                    InteractionTracker.update subMsg model.subscribeTracker
            in
            ( { model | subscribeTracker = subscribeTracker }
            , cmd
            )

        Submit ->
            let
                ( timer, cmd ) =
                    Timer.setTimeout timerConfig model.timer
            in
            ( { model | timer = timer, isSubmitting = True }
            , cmd
            )

        ExpiredTimer ->
            ( { model
                | checkIn = CheckIn.form
                , nameTracker = InteractionTracker.init
                , checkInTracker = InteractionTracker.init
                , checkInTimeTracker = InteractionTracker.init
                , checkOutTracker = InteractionTracker.init
                , timer = Timer.init
                , isSubmitting = False
                , maybeOutput = Form.validateAsMaybe model.checkIn
              }
            , Cmd.none
            )

        ChangedTimer subMsg ->
            ( model
            , Timer.update timerConfig subMsg model.timer
            )
Enter fullscreen mode Exit fullscreen mode

View

view : Model -> H.Html Msg
view { today, checkIn, nameTracker, checkInTracker, checkInTimeTracker, checkOutTracker, subscribeTracker, timer, isSubmitting, maybeOutput } =
    let
        fields =
            Form.toFields checkIn
    in
    H.div []
        [ H.h2 [] [ H.text "Check In" ]
        , H.form
            [ HA.novalidate True
            , HE.onSubmit Submit
            ]
            [ viewInteractiveInput
                { id = "name"
                , label = "Name"
                , type_ = "text"
                , field = fields.name
                , tracker = nameTracker
                , errorToString = CheckIn.nameErrorToString
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs = [ HA.autofocus True ]
                , onInput = InputName
                , onChange = ChangedNameTracker
                }
            , viewInteractiveInput
                { id = "checkIn"
                , label = "Check In"
                , type_ = "date"
                , field = fields.checkIn
                , tracker = checkInTracker
                , errorToString = Date.errorToString "check in"
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs =
                    let
                        maybeMax =
                            fields.checkOut
                                |> F.toMaybe
                                |> Maybe.map (Date.toString << Date.add -1)
                    in
                    [ HA.min (Date.toString <| Date.add 1 today)
                    ]
                        ++ (case maybeMax of
                                Just max ->
                                    [ HA.max max ]

                                Nothing ->
                                    []
                           )
                , onInput = InputCheckIn
                , onChange = ChangedCheckInTracker
                }
            , viewInteractiveInput
                { id = "checkInTime"
                , label = "Check In Time"
                , type_ = "time"
                , field = fields.checkInTime
                , tracker = checkInTimeTracker
                , errorToString = Time.errorToString "check in"
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs = []
                , onInput = InputCheckInTime
                , onChange = ChangedCheckInTimeTracker
                }
            , viewInteractiveInput
                { id = "checkOut"
                , label = "Check Out"
                , type_ = "date"
                , field = fields.checkOut
                , tracker = checkOutTracker
                , errorToString = Date.errorToString "check out"
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs =
                    [ fields.checkIn
                        |> F.toMaybe
                        |> Maybe.map (Date.add 1)
                        |> Maybe.withDefault (Date.add 2 today)
                        |> Date.toString
                        |> HA.min
                    ]
                , onInput = InputCheckOut
                , onChange = ChangedCheckOutTracker
                }
            , viewCheckbox
                { id = "subscribe"
                , label = "Sign Up for Email Updates"
                , field = fields.subscribe
                , tracker = subscribeTracker
                , isRequired = True
                , isDisabled = isSubmitting
                , attrs = []
                , onToggle = ToggleSubscribe
                , onChange = ChangedSubscribeTracker
                }
            , H.p []
                [ H.button
                    [ HA.disabled (Form.isInvalid checkIn || isSubmitting) ]
                    [ H.text <|
                        if isSubmitting then
                            "Checking In..."

                        else
                            "Check In"
                    ]
                ]
            ]
        , case maybeOutput of
            Just { name, stay, isSubscribed } ->
                H.div []
                    [ H.h2 [] [ H.text "Output" ]
                    , H.p [] [ H.text <| "Name: " ++ name ]
                    , H.p [] [ H.text <| "Nights: " ++ String.fromInt stay.nights ]
                    , H.p [] [ H.text <| "Email Updates: " ++ boolToString isSubscribed ]
                    ]

            Nothing ->
                H.text ""
        ]
Enter fullscreen mode Exit fullscreen mode

Notice how the min and max attributes of the date input fields are set. Everything else is pretty much similar to how the complex sign up view was implemented.

Bonus: InteractionTracker

I didn't want to worry about which form control you needed to track. I figured the logic was independent of all that. After several refactoring sessions I realized I just needed a way to hijack the event handlers you placed on your controls so that I could hook into them and do my tracking.

module Lib.InteractionTracker exposing
    ( Messages
    , Msg
    , State
    , Status(..)
    , init
    , toMessages
    , toStatus
    , update
    )

import Lib.Task as Task
Enter fullscreen mode Exit fullscreen mode

State

The internal state I need to know about over time.

type State
    = State
        { status : Status
        }


type Status
    = NotVisited
    | Focused
    | Changed
    | Blurred


init : State
init =
    State
        { status = NotVisited
        }

toStatus : State -> Status
toStatus (State { status }) =
    status
Enter fullscreen mode Exit fullscreen mode

Update

type Msg msg
    = Focus
    | Input (String -> msg) String
    | Blur


update : Msg msg -> State -> ( State, Cmd msg )
update msg (State state) =
    case msg of
        Focus ->
            if state.status == Blurred then
                ( State state, Cmd.none )

            else
                ( State { state | status = Focused }
                , Cmd.none
                )

        Input onInput s ->
            ( if state.status == Blurred then
                State state

              else
                State { state | status = Changed }
            , Task.dispatch (onInput s)
            )

        Blur ->
            ( State { state | status = Blurred }
            , Cmd.none
            )
Enter fullscreen mode Exit fullscreen mode

Messages

I will give you the messages you can use on your controls if you want to track the user's interactions with your widget. I can't know ahead of time which HTML elements they'd need to be placed on, that's for you to decide.

type alias Messages msg =
    { focus : msg
    , input : (String -> msg) -> String -> msg
    , blur : msg
    }


toMessages : (Msg msg -> msg) -> Messages msg
toMessages onChange =
    let
        onInput toMsg =
            onChange << Input toMsg
    in
    { focus = onChange Focus
    , input = onInput
    , blur = onChange Blur
    }
Enter fullscreen mode Exit fullscreen mode

So how do we reuse this?

Enter InteractiveInput

InteractiveInput is a reusable view for tracking user interactions. It doesn't care what control you're tracking.

module Lib.InteractiveInput exposing
    ( InputOptions
    , ViewOptions
    , view
    )

import Field as F exposing (Error, Field)
import Html as H
import Html.Attributes as HA
import Lib.InteractionTracker as InteractionTracker


type alias ViewOptions a msg =
    { id : String
    , label : String
    , field : Field a
    , tracker : InteractionTracker.State
    , errorToString : Error -> String
    , debug : Bool
    , toInput : InputOptions a msg -> H.Html msg
    , onChange : InteractionTracker.Msg msg -> msg
    }


type alias InputOptions a msg =
    { id : String
    , field : Field a
    , focus : msg
    , input : (String -> msg) -> String -> msg
    , blur : msg
    }


view : ViewOptions a msg -> H.Html msg
view { id, label, field, tracker, errorToString, debug, toInput, onChange } =
    let
        status =
            InteractionTracker.toStatus tracker

        { focus, input, blur } =
            InteractionTracker.toMessages onChange
    in
    H.p [] <|
        [ H.label [ HA.for id ] [ H.text (label ++ ": ") ]
        , H.text " "
        , toInput
            { id = id
            , field = field
            , focus = focus
            , input = input
            , blur = blur
            }
        ]
            ++ (if debug then
                    [ H.text " "
                    , H.span [] [ H.text <| statusToString status ]
                    ]

                else
                    []
               )
            ++ [ if status == InteractionTracker.Blurred then
                    field
                        |> F.allErrors
                        |> List.map
                            (\e ->
                                H.li [ HA.style "color" "red" ] [ H.text <| errorToString e ]
                            )
                        |> H.ul []

                 else
                    H.text ""
               ]


statusToString : InteractionTracker.Status -> String
statusToString status =
    case status of
        InteractionTracker.NotVisited ->
            "Not visited"

        InteractionTracker.Focused ->
            "Focused"

        InteractionTracker.Changed ->
            "Changed"

        InteractionTracker.Blurred ->
            "Blurred"
Enter fullscreen mode Exit fullscreen mode

viewInteractiveInput

viewInteractiveInput :
    { id : String
    , label : String
    , type_ : String
    , field : Field a
    , tracker : InteractionTracker.State
    , errorToString : Error -> String
    , isRequired : Bool
    , isDisabled : Bool
    , attrs : List (H.Attribute msg)
    , onInput : String -> msg
    , onChange : InteractionTracker.Msg msg -> msg
    }
    -> H.Html msg
viewInteractiveInput { id, label, type_, field, tracker, errorToString, isRequired, isDisabled, attrs, onInput, onChange } =
    InteractiveInput.view
        { id = id
        , label = label
        , field = field
        , tracker = tracker
        , errorToString = errorToString
        , debug = False
        , toInput =
            toFieldInput
                { isRequired = isRequired
                , isDisabled = isDisabled
                , onInput = onInput
                , attrs = attrs ++ [ HA.type_ type_ ]
                }
        , onChange = onChange
        }


toFieldInput :
    { isRequired : Bool
    , isDisabled : Bool
    , onInput : String -> msg
    , attrs : List (H.Attribute msg)
    }
    -> InteractiveInput.InputOptions a msg
    -> H.Html msg
toFieldInput { isRequired, isDisabled, onInput, attrs } { id, field, focus, input, blur } =
    Input.view
        { field = field
        , isRequired = isRequired
        , isDisabled = isDisabled
        , onInput = input onInput
        , attrs =
            attrs
                ++ [ HA.id id
                   , HE.onFocus focus
                   , HE.onBlur blur
                   ]
        }
Enter fullscreen mode Exit fullscreen mode

Conclusion

After completing this exercise I'm getting more confident that my field abstraction which builds on dwayne/elm-validation and a few other ideas is a good starting point for a form abstraction. It seems to be allowing me to achieve the separation of concerns that I want in a form library. I want to be able to write down the requirements of my forms once and for all and then I want to be able to view that form however I please with any UX I desire.

I didn't even get to talk about the unit testing story. Suffice it to say that's it's easy to test your forms and you don't have to use avh4/elm-program-test to test your form logic. See this comment to understand what I'm referring to.

I plan to find more examples to rebuild with my abstractions in order to see where, if at all, my abstractions fall apart. It will be a while before I release anything.

Request for feedback

In the meantime, I'd be happy to receive stories or examples of forms you found quite challenging to build with the current form abstractions that the Elm ecosystem provides.

What do you think is difficult about forms that I may have overlooked?

Subscribe to my newsletter

If you're interested in improving your skills with Elm then I invite you to subscribe to my newsletter, Elm with Dwayne. To learn more about it, please read this announcement.

Top comments (2)

Collapse
 
xoma_v2 profile image
Nikita

Hi, it's a great article, but it is hard to evaluate your approach without the source code. In particular, I would like to see how the Field type morphs since part 4. Could you please create a repo?

Collapse
 
dwayne profile image
Dwayne Crooks • Edited

Thanks. I'm still working on it and I plan to make it public once I'm satisfied with the public API.

These are currently the main types:

type Field e a
    = Field (Type e a) (State e a)

type alias State e a =
    { raw : Raw
    , processed : Validation e a
    }

type Raw
    = Initial String
    | Dirty String

type alias Type e a =
    { fromString : String -> Result e a
    , fromValue : a -> Result e a
    , toString : a -> String
    }
Enter fullscreen mode Exit fullscreen mode

Here's an example of how Type is used:

int : Type (Error e) Int
int =
    subsetOfInt (always True)

nonNegativeInt : Type (Error e) Int
nonNegativeInt =
    subsetOfInt ((<=) 0)

positiveInt : Type (Error e) Int
positiveInt =
    subsetOfInt ((<) 0)

nonPositiveInt : Type (Error e) Int
nonPositiveInt =
    subsetOfInt ((>=) 0)

negativeInt : Type (Error e) Int
negativeInt =
    subsetOfInt ((>) 0)

subsetOfInt : (Int -> Bool) -> Type (Error e) Int
subsetOfInt =
    customSubsetOfInt
        { blank = Blank
        , syntaxError = SyntaxError
        , validationError = ValidationError << String.fromInt
        }

customSubsetOfInt :
    { blank : e
    , syntaxError : String -> e
    , validationError : Int -> e
    }
    -> (Int -> Bool)
    -> Type e Int
customSubsetOfInt errors isGood =
    customInt
        { blank = errors.blank
        , syntaxError = errors.syntaxError
        }
        (\n ->
            if isGood n then
                Ok n

            else
                Err (errors.validationError n)
        )

customInt :
    { blank : e
    , syntaxError : String -> e
    }
    -> (Int -> Result e Int)
    -> Type e Int
customInt errors validate =
    { fromString =
        customTrim errors.blank
            (\s ->
                case String.toInt s of
                    Just n ->
                        validate n

                    Nothing ->
                        Err (errors.syntaxError s)
            )
    , fromValue = validate
    , toString = String.fromInt
    }
Enter fullscreen mode Exit fullscreen mode

I provide field types for all primitive Elm types (Int, Float, Bool, Char, String) and also give you the ability to make field types out of your user-defined data types.

This happens in the Field.Advanced module where you have full control over the custom error type. But then I'm creating a Field module which defaults to string errors and other simplifications which makes it easier to get started using the library.