How to handle map type in a Phoenix form ?
As I was playing around with Phoenix
and Ecto
types, I decided to experiment with the map
type to reproduce what SoundCloud allows you to do with links on a profile. The platform allows users to setup as many social links as they want.
As I am writing these lines, there is no out-of-the-box support for map
types in Phoenix forms (Phoenix 1.6.2). Here's how I tackled it :
Schema
Let's start with the schema :
For demonstration purposes I will only use the relevant field ⚠️
Migration :
def change do
create table(:profiles) do
...
add :socials, :map, default: %{}
timestamps()
end
end
Elixir module :
schema "profiles" do
...
field :socials, :map, default: %{}
timestamps()
end
Form
If you used the CLI to create your new resource, there is a good chance that your form look like this :
<.form
let={f}
for={@changeset}
...>
<%= label f, :socials %>
<%= text_input f, :socials %>
<%= error_tag f, :socials %>
...
</.form>
It should result in an error when visiting the relevant page...
Let's make some changes.
Changes
What we want for each entry in the map is :
- An input for the key
- An input for the value
- A button to remove an entry from the map
An HTML representation of the above would be this :
<div class="input-button">
<input name="profile[fsocials][]" type="text" value={key} />
<input name="profile[fsocials][]" type="text" value={value} />
<button type="button">Remove social</button>
</div>
You might have noticed the name attribute.
Since there is no way to represent a map
within a form, I decided to use an array virtual field named fsocials
, with the "f" standing for "form".
It will also prevent the validate
handler when filling the form (if you are using a LiveView form) from returning an error, the real field being a map and the form returning a list creates the error.
Add the following to your schema :
field :fsocials, {:array, :string}, default: [], virtual: true
Before diving into writing the HTML helper, there is something that needs to be done first in the Form Component.
Form Component
@impl true
def update(%{profile: profile} = assigns, socket) do
changeset = Example.change_profile(profile)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)
|> assign(:fsocials, Enum.to_list(profile.socials))
end
We assign fsocials
to the socket as the collection that our helper will use.
Enum.to_list(%{"key" => "value"})
# Becomes [{"key", "value"}]
Helper
The helper is invoked like this :
<%= map_input form, :fsocials, @socials, @myself, [[placeholder: "key"], [placeholder: "value"]] %>
Let's dive into the code and break it apart.
def map_input(form, field, collection, target, opts \\ [[], []]) do
for {map, index} <- Enum.with_index(collection) do
[key_opts, value_opts] = update_opts(opts, form, field, map, index)
content_tag :div, class: "input-button" do
[
generic_input(form, field, key_opts),
generic_input(form, field, value_opts),
content_tag(
:button,
type: :button,
class: "alert-danger",
phx_click: "remove",
phx_value_index: index,
phx_value_attribute: field,
phx_target: target
) do
"Remove"
end
]
end
end
end
Let's focus on what's inside the content_tag
first.
We have a parent <div>
with a class input-button
(that applies display: flex
) in which we have 2 inputs and a remove button with a phx-click
directive. Just like we wanted.
We will discuss about event handlers a little bit later !
Keep in mind, the brackets surrounding our 3 methods are required, otherwise, only the last one (button) is displayed !
Also type: :button
is important. It tells Liveview to treat the button as a button that won’t trigger any DOM event such as submit.
generic_input/4 is a private function from the Phoenix.HTML.Form module that I borrowed and tweaked it to my likings.
@default_opts [required: true, type: :text]
defp generic_input(form, field, opts)
when is_list(opts) and (is_atom(field) or is_binary(field)) do
opts =
Keyword.merge(@default_opts, opts)
|> Keyword.put_new(:name, input_name(form, field) <> "[]")
|> Keyword.update!(:value, &maybe_html_escape/1)
tag(:input, opts)
end
Here, we are using the same method with arity 3 instead. Options passed as arguments are responsible for the input type. In our case, inputs will be required
and of type text
. Any additional options get merged with the default ones. Then we add a set of empty brackets next to the field name, finally we are making value html safe.
defp maybe_html_escape(nil), do: nil
defp maybe_html_escape(value), do: html_escape(value)
html_escape/1 is a method from the Phoenix.HTML.html
module.
Now, about the line above the content_tag
, it is responsible to separate key and value options, set their respective values and create unique DOM ids using index.
defp update_opts([key_opts, value_opts], form, field, {key, value}, index) do
key_opts =
key_opts
|> Keyword.put_new(:id, "#{input_id(form, field)}_key_#{index}")
|> Keyword.put_new(:value, key)
value_opts =
value_opts
|> Keyword.put_new(:id, "#{input_id(form, field)}_value_#{index}")
|> Keyword.put_new(:value, value)
[key_opts, value_opts]
end
Now that the helper is ready, let's make some changes to the view.
Form View
We can now update our view with the following :
<%= label f, :socials %>
<%= map_input f, :fsocials, @fsocials, @myself, [[placeholder: "Github", phx_debounce: "blur"], [placeholder: "https://github.com/username", type: :url, phx_debounce: "blur"]] %>
<button type="button" phx-click="add" phx-value-attribute="fsocials" phx-target={@myself}>Add social</button>
<%= error_tag f, :socials %>
Once again, we introduced a new button with a phx-click
directive. Time for us to handle those calls in the Form Component.
Form Component
Back in the form component, we need to implement our handlers for both add
and remove
event. We also need to tweak the validate
handler. These methods have been written in a reusable way so you can have multiple map
fields in a form and use the same methods.
Validate event
With the way we wrote the name
attribute in the input
, for a given key/value pair, it will be received as ["key", "value"]
. Now, if you remember, we expect our collection to be a list of tuples
and not a list of strings
.
With this helper function, we can now modify our validate
handler.
@impl true
def handle_event("validate", %{"business" => business_params}, socket) do
changeset =
socket.assigns.business
|> Core.change_business(business_params)
|> Map.put(:action, :validate)
fsocials = format_virtual_params(business_params, "fsocials")
{:noreply,
assign(socket, changeset: changeset, fsocials: fsocials)}
end
Add / Remove Handlers
Simple stuff, we append a 2-item tuple to our existing fsocials
assign.
Here, it will delete the tuple
from the fsocials
assign at the relevant index. Now that we took care of all of this, we can move onto our final step : form submission.
Submit
Before submitting the form, we need to format fsocials
into a map
and assign it to the real socials
field.
# Turned into a map
socials = Enum.into(socket.assigns[:fsocials], %{})
# Update params before submitting
params = Map.put(profile_params, "socials", socials)
case Example.create_profile(params) do
...
end
Voila !
One more thing I need to address is the @myself
, this special variable is not available within the helper, so I had to pass it explicitly. I don't know if it is the best way to handle map
types so feel free to take inspiration from this and even comment on how you came up with a better solution to this problem.
Happy coding !