site.lyte.dev/content/blog/elm-form-fields-abstraction...

262 lines
10 KiB
Markdown
Raw Permalink Normal View History

2020-07-14 16:47:54 -05:00
---
image: "/img/dried-lava.jpg"
date: "2019-03-20T20:24:54-05:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.5
heroBackgroundColor: "#333"
description: "If you're using Elm, you're probably dealing with forms and
fields. Here's how I did!"
title: "Elm: Forms, Fields, and Abstractions"
draft: false
---
If you're using Elm, you're probably dealing with forms and fields. You're also
probably wanting to do functional programming "right" and compose smaller
functions together. Hopefully this helps!
<!--more-->
# TL;DR
Check it out in context on
<a href="https://ellie-app.com/53ypTnnFykXa1" target="_blank">Ellie</a>,
the Elm playground!
## How Did I Get Here?
To preface this, I'm going to be writing a *lot* of forms with a *lot* of
fields, all of which will pretty much work exactly the same, so creating a layer
of abstraction to boost my productivity down the line is going to be massively
important when it comes to changing things.
You may have even found or been told to use [this library][1]. If you're really
nuts, you may have even tried to use it before finding out that as soon as you
want to change the default renderings at all, you are [encouraged][2] to [copy
and paste this 700+ line file][3] and tweak accordingly, all the while being
told it's not really a big deal.
To be fair, technically this is appropriately "composable", as you are given the
minimum functioning piece so that you can bolt whatever you need onto it, but as
soon as a library or module isn't *easily* doing what I need it to do, instead
of copying code from it, I'm just going to roll my own, which is generally
a really good way to learn something anyways. So, while frustrated, I was also
eager to tackle the problem.
## Code Already!
Since the Elm compiler is so awesome, I've taken to what I'm calling
"compiler-driven development", which is where I write out what I expect to work
and then proceed to make it work.
As a simple test, I know I'm going to need fields that let a user input currency
and percentages. I know that the only real differences that need to be
abstracted away will be the type (currency, percentage, etc.), the getter they
can use to display their current value from my `Model`, and the `Msg`
constructor they will use to update that `Model` as the user fires `input`
events. There will also be tooltips, validation, and other such things, but for
the sake of simplicity, let's focus on these. So, my code will look something
like the following:
```elm
-- ... imports and module definitions ...
main =
Browser.element
{ init = init
, update = update
, subscriptions = \m -> Sub.none
, view = view
}
type alias Model =
{ amount : String
, ratePercent : String
}
init : () -> ( Model, Cmd msg )
init _ =
( { amount = "$1000.87" |> currency
, ratePercent = "3.427857%" |> percent
}
, Cmd.none
)
type Msg
= UpdateAmount String (Cmd Msg)
| UpdateRatePercent String (Cmd Msg)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
UpdateAmount s c -> ( { model | amount = s }, c )
UpdateRatePercent s c -> ( { model | ratePercent = s }, c )
view : Model -> Html Msg
view model =
Html.div []
[ form model
[ currencyField "Amount" .amount UpdateAmount
, percentField "Rate" .ratePercent UpdateRatePercent
]
```
I want this code to handle rendering my fields, formatting and un-formatting the
text input's value (more on this later), and firing update `Msg`s accordingly.
So we just need to implement the `form` function and the `currencyField` and
`percentField` functions.
Note that I'm storing my values as a string in this case, even though my intent
is for them to be numbers. I'm sure there is a cool Elm way to handle this using
the type system, but I haven't got that far. For the moment I'm just extracting
the value from them whenever the calculations need to be done (not shown here).
If and when that changes, perhaps I can make that another blog post explaining
that process as well!
Let's start with form, since it should be the simplest.
```elm
form : model -> List (model -> Html msg) -> Html msg
form model fields =
Html.form []
(List.map (\f -> f model) fields)
```
All the `form` function does is create an `Html.form` whose children are just
the result of mapping over the list of fields, calling "view-like" functions,
passing the model. Easy. Now your form function may need stuff like submit
buttons or other actions, but mine does not. Again, do what is necessary for
*your* application!
Now, with some forward thinking, I know both my `currencyField` and
`percentField` are going to do similar things with minor differences. Let's talk
about those for a moment.
My inputs need to show human-friendly values while idle, but once they're
focused, they should show a more "raw" value so that editing the numerical
values is easy and the user doesn't need to manage commas, units, or anything
else; they just enter a number and the app will show them a nicely-formatted
version of it. Most high-tech spreadsheets work similarly. I'll use `onFocus`
and `onBlur` events to accomplish this.
So let's do some more "compiler-driven development" (this will get a little
nuts):
```elm
currencyField : String -> (model -> String) -> (String -> Cmd msg -> msg) -> model -> Html msg
currencyField label getter updateMsg model =
formattedNumberField label (\m -> m |> getter |> currency) getter updateMsg model
percentField : String -> (model -> String) -> (String -> Cmd msg -> msg) -> model -> Html msg
percentField label getter updateMsg model =
formattedNumberField label (\m -> m |> getter |> percent) getter updateMsg model
```
Whew! I know these type signatures can look intimidating if you're new to Elm,
but here's the breakdown of the arguments:
1. The field's label (as we've seen in the call)
2. A getter, which, given a model, can provide the text that the input should
display (as we've seen in the call - [here's an explanation][4])
3. A `Msg` constructor that takes a `String` (which is passed from `onInput`)
and a `Cmd msg`, since in some cases we might wanna do that, but I won't get
into that here (as we've seen in the call)
4. The model
Okay! That all makes sense. Now what's up with `formattedNumberField`? That's
the next layer of the abstraction onion I'm building up. It's for any number
field that needs to unformat with `onFocus` and reformat with `onBlur`. Also,
we're going to ignore `currency` and `percent` (thought the full code will have
it) as that brings in a whole lot of code that's mostly unrelated to the core
concept I'm trying to convey here. Basically, they take a string like "4.8" and
convert that to "$4.80" or "4.80%".
Now we might also have a number field that doesn't necessarily care about
handling `onFocus` or `onBlur`, so we'll have another abstraction layer there
called `numberField`. Let's see the code:
```elm
type alias FieldConfig t model msg =
{ label : String
, value : model -> t
, onInput : t -> msg
, onFocus : model -> msg
, onBlur : model -> msg
, class : model -> String
}
numberField : FieldConfig String model msg -> model -> Html msg
numberField config model =
Html.div [ Html.Attributes.class "field" ]
[ Html.label []
[ Html.text config.label
]
, Html.input
[ Html.Attributes.value (config.value model)
, Html.Attributes.class (config.class model)
, Html.Attributes.pattern "[0-9\\.]"
, Html.Attributes.id
(config.label
|> String.replace " " "_"
)
, Html.Events.onInput config.onInput
, Html.Events.onFocus (config.onFocus model)
, Html.Events.onBlur (config.onBlur model)
]
[]
]
formattedNumberField : String -> (model -> String) -> (model -> String) -> (String -> Cmd msg -> msg) -> model -> Html msg
formattedNumberField label formatter getter updateMsg model =
numberField
-- FieldConfig
{ label = label
, value = getter
, onInput = \s -> updateMsg s Cmd.none
, onFocus = \m -> updateMsg (m |> getter |> unFormat) (selectElementId label)
, onBlur = \m -> updateMsg (formatter m) Cmd.none
, class = \m -> m |> getter |> unFormat |> String.toFloat |> numberFieldClass
}
model
```
Alrighty! Our biggest chunk. The first block is just a type that tells the
compiler what kind of things we're going to need to build a field. Most of them
are functions that take a model and result in either the type of the field,
a msg to update our model with, or some attribute used within the field for
UX-related things, such as validation.
The second chunk, `numberField` is perhaps the most familiar to newbies and
straightforward. It takes a `FieldConfig`, which we just talked about, and
a model and turns that into HTML that can trigger the messages as specified in
the config. At a glance, it looks pretty magical, but hopefully the next chunk
makes it clear.
`formattedNumberField` specifies the `FieldConfig` which houses all the magic.
It's pretty much a bunch of lambda functions that know how to take a model and
create a `Msg`, which runs through our `update` function, which is that really
important piece of [The Elm Architecture][5].
There are a lot of functions in there like `unFormat`, `numberFieldClass`, and
`selectElementId` that you can check out in the full example, but (again) aren't
really relevant to the core of what I'm talking about.
Hopefully this all makes sense. If it doesn't, check out the full example after
the jump for a working example that you can play with. If this is "Elm 101" to
you, awesome! It took me some serious mind-bending and frustration with Elm's
incredible type checking system not implicitly understanding what I'm trying to
do (computers, amirite?) before I made it to this point.
Now, I'm confident Elm is a tool that can do everything I need it to do. I look
forward to using it more in the future!
## Full Source
Check it out in context on
<a href="https://ellie-app.com/53ypTnnFykXa1" target="_blank">Ellie</a>,
the Elm playground!
[1]: https://github.com/hecrj/composable-form
[2]: https://github.com/hecrj/composable-form/blob/dd846d84de9df181a9c6e8803ba408e628ff9a93/src/Form/Base.elm#L26
[3]: https://github.com/hecrj/composable-form/blob/dd846d84de9df181a9c6e8803ba408e628ff9a93/src/Form.elm
[4]: https://elm-lang.org/docs/records#access
2020-07-29 13:32:00 -05:00
[5]: https://guide.elm-lang.org/architecture/