Making a CheckboxGroup Input

Illustration with a pencil checking multiple selection boxes.
Image by Annie Ruygt

This post builds on the previous work with tagging database records. Here we build a custom multi-select checkbox group input for selecting which tags to associate with a database record in our Phoenix application. Fly.io is a great place to run Phoenix applications! Check out how to get started!

UPDATED: This was updated to support clearing a list of tags. The underlying tag functions were updated and a hidden input ensures a value is passed. See the updated gist as well.

Phoenix 1.7.0 brings a lot of new things when we run mix phx.gen my_app. These new and cool ways of doing things aren’t automatically brought to existing projects because they come from the generators. This means you don’t need to adopt any of these new approaches. After all, Phoenix 1.7 is backward compatible!

However, of the many new things we could bring to an existing project, we’ll focus here on the new approach to form input components. Why? Because it’s both cool and useful!

Earlier we saw how we can play with new Phoenix features. If you’ve played with the 1.7.0 release, you may have noticed the slick new core_components.ex approach. This file is generated for new projects. Here we’ll explore how to build a custom input component that follows this design.

Problem

We like the new core_components.ex approach that fresh Phoenix apps get. The file is generated into a project and is intended to be customized.

The first step towards customizing the file is to change the CSS classes to match the look and feel of our application. By default, it uses Tailwind CSS but those can be replaced or customized as we see fit.

The next step is to create custom components that are useful in our application.

How do we create a custom input in core_component.ex? It’s actually easy. The component we want is a multi-select checkbox group input. It’s perfect for a “check all that apply” or when you have a list of tags that people can choose from.

In our application, a book can be tagged with the genres that apply to it. The input should look something like this:

Screenshot of a multi-select checkbox group input with book genre names.

Ideally, we want the this input to behave like a normal HTML input linked to a Phoenix form and changeset. The question is, how do we create a custom multi-select checkbox group input using the new core_components.ex design?

Solution

Before we dive headlong into creating our new component, let’s do some reconnaissance and get the “lay of the land”.

Our first peek at core_components.ex

When we generate a new Phoenix 1.7.x project, it creates a core_components.ex file for us. For those who haven’t checked it out yet, it contains a number of components we can use and extend, but here we’ll focus on the input function.

The input function has multiple versions that use pattern matching to determine which function body is executed.

Here’s a simplified view:

  def input(%{type: "checkbox"} = assigns) do
    # ...
  end

  def input(%{type: "select"} = assigns) do
    # ...
  end

  def input(%{type: "textarea"} = assigns) do
    # ...
  end

  # ...

A pattern match on the type assign signals what type of component to render. Nice! This makes it easy for us to add a custom type!

Using the component in a HEEx template looks like this:

<.input field={@form[:title]} type="text" label="Title" />

Our multi-select checkbox group’s data

Previously we talked about the underlying database structure and the GIN index that makes it all speedy. Let’s review briefly what the Ecto schema looks like, since the input is sending data for the genres field.

Our book.ex schema:

schema "books" do
  field :title, :string, required: true
  # ...
  field :genres, {:array, :string}, default: [], required: true
  # ...
end

We’re taking advantage of Ecto’s support for fields of type array of string. Time to put that feature to use!

New input type

Our new input type needs a name. It displays a group of checkboxes for the possible list of tags to apply. So, let’s call our new input type a checkgroup.

Building on the existing design, let’s add our new input with a pattern match on the new type. Be sure to group this with all the other input/1 functions. It will look like this:

def input(%{type: "checkgroup"} = assigns) do
  # ...
end

Using our component for book genres in a HEEx template will look like this:

<.input
  field={@form[:genres]}
  type="checkgroup"
  label="Genres"
  multiple={true}
  options={Book.genre_options()}
/>

This is the first time we’re looking at the input’s usage. There are a few points to note.

  • Instead of passing a changeset, we use a Phoenix.HTML.Form and index into it for the field. The field is a %Phoenix.HTML.FormField{} struct. This is how the new input components work in core_components.ex.
  • There is an option called multiple that must be set to true. More on this in a second.
  • options provides a list of the possible tags/genres to display. Inputs of type "select" already support options that conform to Phoenix.HTML.Form.options_for_select/2.

Multiple?

The multiple={true} attribute is really important. As I started using the new input component, I kept forgetting to include the multiple={true} setting. What happened? It didn’t error, but it only sent one checked value for the form. So… it was quietly broken. Why?

In general, in HTML, if we create multiple checkboxes, each with the same name of name="genres[]" (note the square brackets!), then Phoenix interprets the set of values as an array of strings for the checked values. This is exactly what we want!

When we neglect to include the option, it doesn’t add the [] to the input name for us and results in an easy-to-create bug.

The multiple option is processed in the default generated input/1 function, so we can’t access it and use it in our pattern matched input(%{type: "checkgroup"}) function.

What to do?

Because this setting is so critical and we don’t ever want to forget it, let’s write a function wrapper to use instead.

  @doc """
  Generate a checkbox group for multi-select.
  """
  attr :id, :any
  attr :name, :any
  attr :label, :string, default: nil
  attr :field, Phoenix.HTML.FormField, doc: "..."
  attr :errors, :list
  attr :required, :boolean, default: false
  attr :options, :list, doc: "..."
  attr :rest, :global, include: ~w(disabled form readonly)
  attr :class, :string, default: nil

  def checkgroup(assigns) do
    new_assigns =
      assigns
      |> assign(:multiple, true)
      |> assign(:type, "checkgroup")

    input(new_assigns)
  end

The bulk of this simple function wrapper is defining the arguments, all of which were borrowed and customized from the existing input/1 function. All the function does is explicitly set multiple to true so we can’t forget it and we set the type since the function name makes the purpose clear.

Now we can use our component like this in our templates:

<.checkgroup field={@form[:genres]} label="Genres" options={Book.genre_options()} />

Looking good!

Next, let’s think about how our list of displayed options works.

Options

We need to decide how our list of genre options should appear. Do we want to show the stored value or do we want a “friendly” display version shown? It might be the difference between displaying “Science Fiction” versus “sci-fi”. The “right” choice depends on our application, the tags, and how they are used.

For our solution, we’d prefer to see the friendly text of “Science Fiction” displayed but store the tag value of “sci-fi”.

Because we are building a custom component, we could structure this any way we want. For consistency, we’ll borrow the same structure used for select inputs and do it like this:

@genre_options [
  {"Fantasy", "fantasy"},
  {"Science Fiction", "sci-fi"},
  {"Dystopian", "dystopian"},
  {"Adventure", "adventure"},
  {"Romance", "romance"},
  {"Detective & Mystery", "mystery"},
  {"Horror", "horror"},
  {"Thriller", "thriller"},
  {"Historical Fiction", "historical-fiction"},
  {"Young Adult (YA)", "young-adult"},
  {"Children's Fiction", "children-fiction"},
  {"Memoir & Autobiography", "autobiography"},
  {"Biography", "biography"},
  {"Cooking", "cooking"},
  # ...
]

Because our list of allowed tags is defined in code, it makes sense to define it with our schema; after all, we will use the values in our validations.

With the above structure, our validations can’t use the data in @genre_options directly. Our validation needs a list of just the valid values. To address this, we can write the following line of code to compute the list of valid values at compile time.

@valid_genres Enum.map(@genre_options, fn({_text, val}) -> val end)

The above code essentially turns into the following:

@valid_genres ["fantasy", "sci-fi", "dystopian", "adventure", ...]

A benefit of using a function at compile time is we don’t have to remember to keep the two lists in sync and it only runs the function once when compiling.

Then, in our changeset, we can use Ecto.Changeset.validate_subset/4 like this:

changeset
# ...
|> validate_subset(:genres, @valid_genres)
# ...

Nice! We can display friendly values to the user but we store and validate using the internal tag values.

Options in the template

Our template can’t access the internal module attribute @genre_options. If we recall back to the HEEx template and how our component will be used, it calls Book.genre_options/0. Template usage looks like this:

<.checkgroup field={@form[:genres]} label="Genres" options={Book.genre_options()} />

Our template needs a public function to call that returns our options for display. Fortunately, this single line of code is all that’s needed:

def genre_options, do: @genre_options

With that, our schema is set to supply the component with everything needed. Let’s take a look at the final version of component!

Component

Here’s the full source for our new “checkgroup” input component. We’ll go over some of the interesting bits next. (NOTE: the Tailwind classes are truncated here.)

defmodule MyAppWeb.CoreComponents do
  use Phoenix.Component

  # ...
  def input(%{type: "checkgroup"} = assigns) do
    ~H"""
    <div phx-feedback-for={@name} class="text-sm">
      <.label for={@id} required={@required}><%= @label %></.label>
      <div class="mt-1 w-full bg-white border border-gray-300 ...">
        <div class="grid grid-cols-1 gap-1 text-sm items-baseline">
          <input type="hidden" name={@name} value="" />
          <div class="..." :for={{label, value} <- @options}>
            <label
              for={"#{@name}-#{value}"} class="...">
              <input
                type="checkbox"
                id={"#{@name}-#{value}"}
                name={@name}
                value={value}
                checked={value in @value}
                class="mr-2 h-4 w-4 rounded ..."
                {@rest}
              />
              <%= label %>
            </label>
          </div>
        </div>
      </div>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end
  # ...
end

Here are some points to note:

  • The function declaration includes the pattern match for type: "checkgroup".
  • A hidden input is included with the value of "". This ensures we can clear all the checked tags and the browser still has a value to submit to the server.
  • The code <div … :for={{label, value} <- @options}> expects our options to be tuples in the format of {label, value}. If using a different options structure, this is where it matters.
  • We render a “checkbox” input for every option. The label is displayed and the value is what is stored.
  • The @name is changed previously by the existing input function that adapts it to end with the [] when we pass multiple={true} to the component. This is important for correctly submitting the values back to the server.

Wrapping up the changes

We get a warning from the input component that the type isn’t valid. The last thing to do before we are done with the component is update the attr :type declaration on the generated input/1 function. We want to add our checkgroup type to the list of valid values. NOTE: The checkgroup entry was added to the end of the values list.

attr :type, :string,
  default: "text",
  values: ~w(checkbox color date datetime-local email file hidden
             month number password range radio search select tel
             text textarea time url week csv checkgroup)

That’s it! Let’s see how it looks and behaves.

(For a complete view of the code, please refer to this Gist.)

Our Component in Action

This is what it looks like in action:

Screenshot of a multi-select checkbox group input with selected book.

Alright! That’s what we want!

The big question now is, “What happens when the form data is submitted to the server?”

Submitting the Values

Our component does the work of creating a correctly configured group of many checkbox inputs. When the form is validated or submitted and the selected genres pictured previously are sent, in Phoenix we receive this in our params:

%{"book" => %{"genres" => ["sci-fi", "dystopian", "romance"]}}

If we recall, our schema is setup to handle data formatted this way perfectly! There is nothing left for us to do! The schema casts the genres list of strings and validates it. It works just as expected!

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!

Discussion

We saw first hand how easy it is to tweak the generated core_components.ex file. Beyond customizing the classes for our application, it is easy to even create new input components!

There are a few other points that came out during this little project.

  • The realization of how easy it was. We added a new input type with a single 25 line function where almost all of it is markup.
  • Our new input integrates smoothly with standard forms and changesets.
  • The previous approach of using Phoenix.HTML.Form.checkbox/3 isn’t used anymore in the new core_components.ex approach. The new approach, particularly around input, goes back to more pure HTML.
  • Creating a simple pre-configured wrapper component like <.checkgroup ...> ensures we won’t forget important settings like the multiple={true} attribute.

In the end, I’m pleased with how well our custom input works with both LiveView and non-LiveView templates.

This completes our UI for adding tag support to a Phoenix application. Go grab the code from this gist and I hope you enjoy customizing your core_components.ex!

And tag on!