site.lyte.dev/content/blog/elm-form-fields-abstractions.md
2024-05-07 09:08:39 -05:00

10 KiB

image date imageOverlayColor imageOverlayOpacity heroBackgroundColor description title draft
/img/dried-lava.jpg 2019-03-20T20:24:54-05:00 #000 0.5 #333 If you're using Elm, you're probably dealing with forms and fields. Here's how I did! Elm: Forms, Fields, and Abstractions 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!

TL;DR

Check it out in context on Ellie, 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. 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 to copy and paste this 700+ line file 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:

-- ... 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 Msgs 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.

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):

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)
  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:

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.

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 Ellie, the Elm playground!