DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on

Diary of an Elm Developer - Exploring an API for form fields

My implementation of L-System Studio required me to use a lot of form fields in interesting ways.

It has input fields that take non-empty strings, non-negative integers, bounded integers, non-negative floats, bounded floats and angles in degrees.

The ability to zoom in depends on the value of zoom increment and window size. For e.g. if the zoom increment is 100 but the window size is 50 then the user shouldn't be allowed to zoom in. The user is only allowed to zoom in if the window size is at least twice the value of the zoom increment.

I found that my field abstraction allowed me to implement these requirements quite simply and because of this I've decided to explore these ideas even further.

More examples of usage

These are the fields I keep in the model:

type alias Model =
    { preset : Field Preset
    , axiom : Field String
    , iterations : Field Int
    , startHeading : Field Angle
    , lineLength : Field Float
    , lineLengthScaleFactor : Field Float
    , turningAngle : Field Angle
    , windowPositionX : Field Float
    , windowPositionY : Field Float
    , windowSize : Field Float
    , fps : Field Int
    , ipf : Field Int
    , panIncrement : Field Float
    , zoomIncrement : Field Float
    -- ...
    }
Enter fullscreen mode Exit fullscreen mode

This is how I initialize the fields:

{ preset = F.fromValue F.preset isInitial preset
, axiom = F.fromString F.nonEmptyString True settings.axiom
, iterations = F.fromValue F.nonNegativeInt True settings.iterations
, startHeading = F.fromValue F.angle True settings.startHeading
, lineLength = F.fromValue F.nonNegativeFloat True settings.lineLength
, lineLengthScaleFactor = F.fromValue F.nonNegativeFloat True settings.lineLengthScaleFactor
, turningAngle = F.fromValue F.angle True settings.turningAngle
, windowPositionX = F.fromValue F.float True settings.windowPosition.x
, windowPositionY = F.fromValue F.float True settings.windowPosition.y
, windowSize = F.fromValue F.nonNegativeFloat True settings.windowSize
, fps = F.fromValue F.fps True settings.fps
, ipf = F.fromValue F.ipf True settings.ipf
, panIncrement = F.fromValue F.panIncrement True 10
, zoomIncrement = F.fromValue F.zoomIncrement True 10
-- ...
}
Enter fullscreen mode Exit fullscreen mode

This is how I validate the fields before rendering:

render : Model -> ( Model, Cmd msg )
render model =
    let
        oldSettings =
            model.settings

        resultNewSettings =
            (\axiom iterations startHeading lineLength lineLengthScaleFactor turningAngle windowPositionX windowPositionY windowSize fps ipf ->
                { oldSettings
                    | rules = Rules.toValue model.rules
                    , axiom = axiom
                    , iterations = iterations
                    , startHeading = startHeading
                    , lineLength = lineLength
                    , lineLengthScaleFactor = lineLengthScaleFactor
                    , turningAngle = turningAngle
                    , windowPosition = { x = windowPositionX, y = windowPositionY }
                    , windowSize = windowSize
                    , fps = fps
                    , ipf = ipf
                }
            )
                |> F.get model.axiom
                |> F.and model.iterations
                |> F.and model.startHeading
                |> F.and model.lineLength
                |> F.and model.lineLengthScaleFactor
                |> F.and model.turningAngle
                |> F.and model.windowPositionX
                |> F.and model.windowPositionY
                |> F.and model.windowSize
                |> F.and model.fps
                |> F.and model.ipf
    in
    case resultNewSettings of
        Ok newSettings ->
            ( { model | settings = newSettings, renderer = initRenderer newSettings }
            , clear ()
            )

        Err _ ->
            ( model
            , Cmd.none
            )
Enter fullscreen mode Exit fullscreen mode

This is how I pan to the left:

ClickedLeft ->
    F.apply2
        (\windowPositionX panIncrement ->
            render { model | windowPositionX = F.fromValue F.float False (windowPositionX - panIncrement) }
        )
        model.windowPositionX
        model.panIncrement
        |> Result.withDefault ( model, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

This is how I zoom out:

ClickedOut ->
    let
        resultWindow =
            F.apply4
                (\x y size inc ->
                    let
                        inc2 =
                            2 * inc
                    in
                    { x = x - inc
                    , y = y - inc
                    , size = size + inc2
                    }
                )
                model.windowPositionX
                model.windowPositionY
                model.windowSize
                model.zoomIncrement
    in
    case resultWindow of
        Ok { x, y, size } ->
            render
                { model
                    | windowPositionX = F.fromValue F.float False x
                    , windowPositionY = F.fromValue F.float False y
                    , windowSize = F.fromValue F.nonNegativeFloat False size
                }

        Err _ ->
            ( model, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

This is how I decide whether or not to disable the zoom in button:

H.button
    [ HA.type_ "button"
    , if isValidZoomIncrement { isZoomingIn = True, windowSize = windowSize, zoomIncrement = zoomIncrement } then
        HE.onClick ClickedIn

      else
        HA.disabled True
    ]
    [ H.text "In" ]
Enter fullscreen mode Exit fullscreen mode

Features

  • Fields keep track of the raw string that the user entered.
  • When you first create the field it's considered clean but if you make changes then it's considered dirty.
  • Fields keep track of the processed value.
  • The processed value could be anything you want. In the examples above it was Int, Float, String, Angle, and Preset.
  • The processed value is stored in a Result so that we can track errors.
  • There's a concept of a field type, Type. A field type can be thought of as an interface of functions that concrete types, like Int, String, or Angle, implement in order to be used as fields. If you're familiar with Haskell, you might make Type a type class. If you're familiar with Rust, you might make Type a trait.

The main types used to achieve the features above:

type Field a
    = Field (State a)


type alias State a =
    { raw : Raw
    , processed : Result Error a
    }


type Raw
    = Initial String
    | Dirty String


type Error
    = Required
    | ParseError
    | ValidationError


type alias Type a =
    { toString : a -> String
    , toValue : String -> Result Error a
    , validate : a -> Result Error a
    }
Enter fullscreen mode Exit fullscreen mode

Here's the full implementation of Field as I envisioned it at the time for L-System Studio.

How I made Angle into a field

Data.Angle was implemented with no regards for fields. But, when I was ready to accept angle input all I had to do was implement toString, toValue, and validate from the Type interface:

angle : F.Type Angle
angle =
    { toString = Angle.toDegrees >> String.fromFloat
    , toValue = F.trim >> Result.andThen (String.toFloat >> Maybe.map (Ok << Angle.fromDegrees) >> Maybe.withDefault F.validationError)
    , validate = Ok
    }
Enter fullscreen mode Exit fullscreen mode

Here's the implementation of all the custom fields not directly provided by the Field module.

What lies ahead?

I'm currently still exploring the space and working on more diverse examples in order to figure out a good API for everything I'm going to need but the work I'm doing is looking really promising.

Most recently I finished a sign up form example. Here's what the code looks like:

module Example1.Data.SignUp exposing
    ( Error(..)
    , SignUp
    , Submission
    , errorToString
    , init
    , setEmail
    , setPassword
    , setPasswordConfirmation
    , setUsername
    , submit
    , toFields
    )

import Example1.Data.Email as Email exposing (Email)
import Example1.Data.Password as Password exposing (Password)
import Example1.Data.Username as Username exposing (Username)
import Field as F exposing (Field)


type SignUp
    = SignUp Fields


type alias Fields =
    { username : Field Username
    , email : Field Email
    , password : Field Password
    , passwordConfirmation : Field Password
    }


init : SignUp
init =
    SignUp
        { username = F.empty Username.fieldType
        , email = F.empty Email.fieldType
        , password = F.empty Password.fieldType
        , passwordConfirmation = F.empty Password.fieldType
        }


setUsername : String -> SignUp -> SignUp
setUsername s (SignUp fields) =
    SignUp { fields | username = F.setFromString s fields.username }


setEmail : String -> SignUp -> SignUp
setEmail s (SignUp fields) =
    SignUp { fields | email = F.setFromString s fields.email }


setPassword : String -> SignUp -> SignUp
setPassword s (SignUp fields) =
    let
        password =
            F.setFromString s fields.password
    in
    SignUp { fields | password = password, passwordConfirmation = updatePasswordConfirmation password fields.passwordConfirmation }


setPasswordConfirmation : String -> SignUp -> SignUp
setPasswordConfirmation s (SignUp fields) =
    let
        passwordConfirmation =
            F.setFromString s fields.passwordConfirmation
    in
    SignUp { fields | passwordConfirmation = updatePasswordConfirmation fields.password passwordConfirmation }


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

        else
            F.fail (F.customError "The password confirmation does not match.") passwordConfirmationField
    )
        |> F.get passwordField
        |> F.and passwordConfirmationField
        |> F.withDefault passwordConfirmationField


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


type alias Submission =
    { username : Username
    , email : Email
    , password : Password
    }


submit : SignUp -> Result (List String) Submission
submit (SignUp fields) =
    (\username email password _ ->
        Submission username email password
    )
        |> F.get (fields.username |> F.mapError UsernameError)
        |> F.and (fields.email |> F.mapError EmailError)
        |> F.and (fields.password |> F.mapError PasswordError)
        |> F.and (fields.passwordConfirmation |> F.mapError PasswordConfirmationError)
        |> F.andResult
        |> Result.mapError (List.map errorToString)


errorToString : Error -> String
errorToString error =
    case error of
        UsernameError e ->
            Username.errorToString e

        EmailError e ->
            Email.errorToString e

        PasswordError e ->
            Password.errorToString "password" e

        PasswordConfirmationError e ->
            Password.errorToString "password confirmation" e


toFields : SignUp -> Fields
toFields (SignUp fields) =
    fields
Enter fullscreen mode Exit fullscreen mode

And here's what it looks like to use it:

import Example1.Data.SignUp as S

S.init
    |> S.setEmail "ab.c"
    |> S.submit
-- Err ["The username is required.","The email is not valid.","The password is required.","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.submit
-- Err ["The username is required.","The password is required.","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dw"
    |> S.submit
-- Err ["The username must have at least 3 characters.","The password is required.","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne aaaaaaaaaaaaaaaaaaaaaaaaa"
    |> S.submit
-- Err ["The username must have at most 25 characters.","The password is required.","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne"
    |> S.submit
-- Err ["The password is required.","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne"
    |> S.setPassword "1234"
    |> S.submit
-- Err ["The password must have at least 8 characters.","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne"
    |> S.setPassword "12345678"
    |> S.submit
-- Err ["The password must contain at least 1 of each of the following: a lowercase character, an uppercase character, a number, and a special character in the set \"(!@#$%^&*)\".","The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne"
    |> S.setPassword "12345678aB$"
    |> S.submit
-- Err ["The password confirmation is required."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne"
    |> S.setPassword "12345678aB$"
    |> S.setPasswordConfirmation "12345678aB$%"
    |> S.submit
-- Err["The password confirmation does not match."]

S.init
    |> S.setEmail "[email protected]"
    |> S.setUsername "dwayne"
    |> S.setPassword "12345678aB$"
    |> S.setPasswordConfirmation "12345678aB$"
    |> S.submit
-- Ok { email = Email "[email protected]", password = Password "12345678aB$", username = Username "dwayne" }
Enter fullscreen mode Exit fullscreen mode

It has a nice unit testing story.

Field and Field.Advanced

I've also decided there's going to be two modules, Field and Field.Advanced. The main difference being that Field would use String for custom errors but with Field.Advanced you're able to use your custom error types to delay converting to a string representation for as long as you need.

Debounced input and async fields

Nothing to talk about here as yet. I'll be looking into this soon.

Request for Feedback

How has your experience been with forms in Elm? Do you have examples of where current Elm libraries fall short? What would you like to see in a form library for Elm?

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 (0)