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.