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.

Sasha Marie's profile links

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 %>
Representation of the piece of code above

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.

defp format_virtual_params(params, name) do
    case params[name] do
      nil -> []
      _ -> params[name] |> Enum.chunk_every(2) |> Enum.map(&List.to_tuple/1)
    end
  end
Helper function to transform our list of strings into a list of tuples

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

  def handle_event("add", %{"attribute" => attribute} = params, socket) do
    attribute = String.to_existing_atom(attribute)
    socket = assign(socket, attribute, socket.assigns[attribute] ++ [{"", ""}])
    {:noreply, socket}
  end
Add handler

Simple stuff, we append a 2-item tuple to our existing fsocials assign.

  def handle_event("remove", %{"index" => index, "attribute" => attribute}, socket) do
    attribute = String.to_existing_atom(attribute)
    collection = List.delete_at(socket.assigns[attribute], String.to_integer(index))
    socket = assign(socket, attribute, collection)
    {:noreply, socket}
  end
Remove handler

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 !