DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on • Edited 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 "a@b.c" |> S.submit -- Err ["The username is required.","The password is required.","The password confirmation is required."] S.init |> S.setEmail "a@b.c" |> 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 "a@b.c" |> 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 "a@b.c" |> S.setUsername "dwayne" |> S.submit -- Err ["The password is required.","The password confirmation is required."] S.init |> S.setEmail "a@b.c" |> 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 "a@b.c" |> 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 "a@b.c" |> S.setUsername "dwayne" |> S.setPassword "12345678aB$" |> S.submit -- Err ["The password confirmation is required."] S.init |> S.setEmail "a@b.c" |> S.setUsername "dwayne" |> S.setPassword "12345678aB$" |> S.setPasswordConfirmation "12345678aB$%" |> S.submit -- Err["The password confirmation does not match."] S.init |> S.setEmail "a@b.c" |> S.setUsername "dwayne" |> S.setPassword "12345678aB$" |> S.setPasswordConfirmation "12345678aB$" |> S.submit -- Ok { email = Email "a@b.c", 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 (2)

Collapse
 
dwayne profile image
Dwayne Crooks

@dirkbj I'm actually working through an example that Dillon shared when he introduced his form library. I found a neat abstraction that I'd write more about soon. Suffice it to say I'm doing something similar to what you mention, which is to abstract away the tracking into it's own data structure separate and apart from the form.

Collapse
 
dirkbj profile image
Dirk Johnson

I am aware of a few form libs in elm. The one my team has used most recently is dillonkearns/elm-form. For our use cases, we have actually abstracted away tracking "field changes" from the form into a separate type called an EditingContext. An EditingContext is not aware of forms, but is attached to a data type, and it keeps track of changes over time to that data until it is "saved". It also allows reverting to previous versions (allowing "undo"), and, though we have not yet added it, would easily allow "redo". This data type may represent a form or something else internal to the app.

Thanks for this excellent writeup.