Dynamic forms with Phoenix
Today we will learn how to build forms in Phoenix that use our schema information to dynamically show the proper input fields with validations, errors and so on. We aim to support the following API in our templates:
<%= input f, :name %>
<%= input f, :address %>
<%= input f, :date_of_birth %>
<%= input f, :number_of_children %>
<%= input f, :notifications_enabled %>
Each generated input will have the proper markup and classes (we will use Bootstrap in this example), include the proper HTML attributes, such as required
for required fields and validations, and show any input error.
The goal is to build this foundation in our own applications in very few lines of code, without 3rd party dependencies, allowing us to customize and extend it as desired as our application changes.
Setting up
Before building our input
helper, let’s first generate a new resource which we will use as a template for experimentation (if you don’t have a Phoenix application handy, run mix phoenix.new your_app
before the command below):
mix phoenix.gen.html User users name address date_of_birth:datetime number_of_children:integer notifications_enabled:boolean
Follow the instructions after the command above runs and then open the form template at “web/templates/user/form.html.eex”. We should see a list of inputs such as:
<div class="form-group">
<%= label f, :address, class: "control-label" %>
<%= text_input f, :address, class: "form-control" %>
<%= error_tag f, :address %>
</div>
The goal is to replace each group above by a single <%= input f, field %>
line.
Adding changeset validations
Still in the “form.html.eex” template, we can see that a Phoenix form operates on Ecto changesets:
<%= form_for @changeset, @action, fn f -> %>
Therefore, if we want to automatically show validations in our forms, the first step is to declare those validations in our changeset. Open up “web/models/user.ex” and let’s add a couple new validations at the end of the changeset
function:
|> validate_length(:address, min: 3)
|> validate_number(:number_of_children, greater_than_or_equal_to: 0)
Also, before we do any changes to our form, let’s start the server with mix phoenix.server
and access http://localhost:4000/users/new
to see the default form at work.
Writing the input
function
Now that we have set up the codebase, we are ready to implement the input
function.
The YourApp.InputHelpers
module
Our input
function will be defined in a module named YourApp.InputHelpers
(where YourApp
is the name of your application) which we will place in a new file at “web/views/input_helpers.ex”. Let’s define it:
defmodule YourApp.InputHelpers do
use Phoenix.HTML
def input(form, field) do
"Not yet implemented"
end
end
Note we used Phoenix.HTML
at the top of the module to import the functions from the Phoenix.HTML
project. We will rely on those functions to build the markup later on.
If we want our input
function to be automatically available in all views, we need to explicitly add it to the list of imports in the “def view” section of our “lib/my_app_web.ex” file:
import YourApp.Router.Helpers
import YourApp.ErrorHelpers
import YourApp.InputHelpers # Let's add this one
import YourApp.Gettext
With the module defined and properly imported, let’s change our “form.html.eex” function to use the new input
functions. Instead of 5 “form-group” divs:
<div class="form-group">
<%= label f, :address, class: "control-label" %>
<%= text_input f, :address, class: "form-control" %>
<%= error_tag f, :address %>
</div>
We should have 5 input calls:
<%= input f, :name %>
<%= input f, :address %>
<%= input f, :date_of_birth %>
<%= input f, :number_of_children %>
<%= input f, :notifications_enabled %>
Phoenix live-reload should automatically reload the page and we should see “Not yet implemented” appear 5 times.
Showing the input
The first functionality we will implement is to render the proper inputs, as before. To do so, we will use the Phoenix.HTML.Form.input_type
function, that receives a form and a field name and returns which input type we should use. For example, for :name
, it will return :text_input
. For :date_of_birth
, it will yield :datetime_select
. We can use the returned atom to dispatch to Phoenix.HTML.Form
and build our input:
def input(form, field) do
type = Phoenix.HTML.Form.input_type(form, field)
apply(Phoenix.HTML.Form, type, [form, field])
end
Save the file and watch the inputs appear on the page!
Wrappers, labels and errors
Now let’s take the next step and show the label and error messages, all wrapped in a div:
def input(form, field) do
type = Phoenix.HTML.Form.input_type(form, field)
content_tag :div do
label = label(form, field, humanize(field))
input = apply(Phoenix.HTML.Form, type, [form, field])
error = YourApp.ErrorHelpers.error_tag(form, field) || ""
[label, input, error]
end
end
We used content_tag
to build the wrapping div
and the existing YourApp.ErrorHelpers.error_tag
function that Phoenix generates for every new application that builds an error tag with proper markup.
Adding Bootstrap classes
Finally, let’s add some HTML classes to mirror the generated Bootstrap markup:
def input(form, field) do
type = Phoenix.HTML.Form.input_type(form, field)
wrapper_opts = [class: "form-group"]
label_opts = [class: "control-label"]
input_opts = [class: "form-control"]
content_tag :div, wrapper_opts do
label = label(form, field, humanize(field), label_opts)
input = apply(Phoenix.HTML.Form, type, [form, field, input_opts])
error = YourApp.ErrorHelpers.error_tag(form, field)
[label, input, error || ""]
end
end
And that’s it! We are now generating the same markup that Phoenix originally generated. All in 14 lines of code. But we are not done yet, let’s take things to the next level by further customizing our input function.
Customizing inputs
Now that we have achieved parity with the markup code that Phoenix generates, we can further extend it and customize it according to our application needs.
Colorized wrapper
One useful UX improvement is to, if a form has errors, automatically wrap each field in a success or error state accordingly. Let’s rewrite the wrapper_opts
to the following:
wrapper_opts = [class: "form-group #{state_class(form, field)}"]
And define the private state_class
function as follows:
defp state_class(form, field) do
cond do
# The form was not yet submitted
is_nil(form.source.action) -> ""
# The field has error
form.errors[field] -> "has-error"
# The field is blank
input_value(form, field) in ["", nil] -> ""
# The field was filled successfully
true -> "has-success"
end
end
Now submit the form with errors and you should see every label and input wrapped in green (in case of success) or red (in case of input error).
Input validations
We can use the Phoenix.HTML.Form.input_validations
function to retrieve the validations in our changesets as input attributes and then merge it into our input_opts
. Add the following two lines after the input_opts
variable is defined (and before the content_tag
call):
validations = Phoenix.HTML.Form.input_validations(form, field)
input_opts = Keyword.merge(validations, input_opts)
After the changes above, if you attempt to submit the form without filling the “Address” field, which we imposed a length of 3 characters, the browser won’t allow the form to be submitted. Not everyone is a fan of browser validations and, in this case, you have direct control if you want to include them or not.
At this point it is worth mentioning both Phoenix.HTML.Form.input_type
and Phoenix.HTML.Form.input_validations
are defined as part of the Phoenix.HTML.FormData
protocol. This means if you decide to use something else besides Ecto changesets to cast and validate incoming data, all of the functionality we have built so far will still work. For those interested in learning more, I recommend checking out the Phoenix.Ecto
project and learn how the integration between Ecto and Phoenix is done by simply implementing protocols exposed by Phoenix.
Per input options
The last change we will add to our input
function is the ability to pass options per input. For example, for a given input, we may not want to use the type inflected by input_type
. We can add options to handle those cases:
def input(form, field, opts \\ []) do
type = opts[:using] || Phoenix.HTML.Form.input_type(form, field)
...
This means we can now control which function to use from Phoenix.HTML.Form
to build our input:
<%= input f, :new_password, using: :password_input %>
We also don’t need to be restricted to the inputs supported by Phoenix.HTML.Form
. For example, if you want to replace the :datetime_select
input that ships with Phoenix by a custom datepicker, you can wrap the input creation into an function and pattern match on the inputs you want to customize.
Let’s see how our input
functions look like with all the features so far, including support for custom inputs (input validations have been left out):
defmodule YourApp.InputHelpers do
use Phoenix.HTML
def input(form, field, opts \\ []) do
type = opts[:using] || Phoenix.HTML.Form.input_type(form, field)
wrapper_opts = [class: "form-group #{state_class(form, field)}"]
label_opts = [class: "control-label"]
input_opts = [class: "form-control"]
content_tag :div, wrapper_opts do
label = label(form, field, humanize(field), label_opts)
input = input(type, form, field, input_opts)
error = YourApp.ErrorHelpers.error_tag(form, field)
[label, input, error || ""]
end
end
defp state_class(form, field) do
cond do
# The form was not yet submitted
is_nil(form.source.action) -> ""
# The field has error
form.errors[field] -> "has-error"
# The field is blank
input_value(form, field) in ["", nil] -> ""
# The field was filled successfully
true -> "has-success"
end
end
# Implement clauses below for custom inputs.
# defp input(:datepicker, form, field, input_opts) do
# raise "not yet implemented"
# end
defp input(type, form, field, input_opts) do
apply(Phoenix.HTML.Form, type, [form, field, input_opts])
end
end
And then, once you implement your own :datepicker
, just add to your template:
<%= input f, :date_of_birth, using: :datepicker %>
Since your application owns the code, you will always have control over the inputs types and how they are customized. Luckily Phoenix ships with enough functionality to give us a head start, without compromising our ability to refine our presentation layer later on.
Summing up
This article showed how we can leverage the conveniences exposed in Phoenix.HTML
to dynamically build forms using the information we have already specified in our schemas. Although the example above used the User schema, which directly maps to a database table, Ecto allows us to use schemas to map to any data source, so the input
function can be used for validating search forms, login pages, and so on without changes.
While there are projects such as Simple Form to tackle those problems in our Rails projects, with Phoenix we can get really far using the minimal abstractions that ship as part of the framework, allowing us to get most of the functionality while having full control over the generated markup.
P.S.: This post was originally published on Plataformatec’s blog and updated since then.