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
-- ...
}
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
-- ...
}
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
)
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 )
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 )
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" ]
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
, andPreset
. - 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, likeInt
,String
, orAngle
, implement in order to be used as fields. If you're familiar with Haskell, you might makeType
a type class. If you're familiar with Rust, you might makeType
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
}
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
}
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
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" }
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)