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:
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 newinput
components work incore_components.ex
. - There is an option called
multiple
that must be set totrue
. 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 toPhoenix.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 existinginput
function that adapts it to end with the[]
when we passmultiple={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:
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!
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 aroundinput
, goes back to more pure HTML. - Creating a simple pre-configured wrapper component like
<.checkgroup ...>
ensures we won’t forget important settings like themultiple={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!