While working on a new project recently, I needed to allow application users to enter a dynamic list of key-value pairs. Not knowing better, I reached for what I knew and built out a React component to dynamically add and remove inputs while also inserting into the websocket to sync the changeset with the server. Then on the server-side, I had to write some additional handlers to process those values. It worked, but it didn’t feel great. I knew there had to be a better way to approach this.
Dynamically Adding and Removing Inputs
I searched the documentation and thought maybe I could do something with an embedded schema for the pairs, but I couldn’t quite seem to get it to work right. ChatGPT wasn’t sure how to do it either, so I decided to ask on the Elixir Slack. I found out I was on the right track; I just needed to combine embeds_many
and inputs_for
to dynamically add and remove inputs.
This really helped me solve the problem I had, so I thought I’d write up a quick post expanding on the documentation linked above to provide a complete, working example with all the steps to get there from a fresh mix phx.new
. You can see the completed example here.
Initial Project Setup
First, we’ll set up a new Phoenix project:
$ mix phx.new dynamic_inputs
This will install and resolve all dependencies, giving us a clean working Phoenix starter project.
Given the fields we have seen in the documentation, we can create a very simple context and schema:
mix phx.gen.live Marketing MailingList mailing_lists title:string
This generator will create fully functional live views for CRUD operations on our MailingList
and output the routes we need to add to lib/dynamic_inputs_web/router.ex
. For this demo, we’ll just put it under the default scope for the application:
scope "/", DynamicInputsWeb do
pipe_through :browser
get "/", PageController, :home
live "/mailing_lists", MailingListLive.Index, :index
live "/mailing_lists/new", MailingListLive.Index, :new
live "/mailing_lists/:id/edit", MailingListLive.Index, :edit
live "/mailing_lists/:id", MailingListLive.Show, :show
live "/mailing_lists/:id/show/edit", MailingListLive.Show, :edit
end
What We Have So Far
If we navigate to http://localhost:4000/mailing_lists
, we’ll see a standard Phoenix list table with a button to add new mailing lists. Click on it, and we get a standard modal dialog asking for a title.
Alright, let’s see what we can do to add name/value pairs to this in the form of a name and email using examples straight from the documentation.
The first change we make is to lib/dynamic_inputs/marketing/mailing_list.ex
:
defmodule DynamicInputs.Marketing.MailingList do
use Ecto.Schema
import Ecto.Changeset
schema "mailing_lists" do
field :title, :string
embeds_many :emails, EmailNotification, on_replace: :delete do
field :email, :string
field :name, :string
end
timestamps(type: :utc_datetime)
end
@doc false
def changeset(mailing_list, attrs) do
mailing_list
|> cast(attrs, [:title])
|> cast_embed(:emails,
with: &email_changeset/2,
sort_param: :emails_sort,
drop_param: :emails_drop)
|> validate_required([:title])
end
defp email_changeset(email, attrs) do
email
|> cast(attrs, [:email, :name])
# Possibly some validations here
end
end
Then next up, we make use of inputs_for
in lib/dynamic_inputs_web/mailing_list_live/form_component.ex
. For this example, I am only sharing the relevant snippet surrounding the usage of inputs_for
; the contents of the file are otherwise unchanged.
<.simple_form
for={@form}
id="mailing_list-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:title]} type="text" label="Title" />
<.inputs_for :let={ef} field={@form[:emails]}>
<input type="hidden" name="mailing_list[emails_sort][]" value={ef.index} />
<.input type="text" field={ef[:email]} placeholder="email" />
<.input type="text" field={ef[:name]} placeholder="name" />
<button
type="button"
name="mailing_list[emails_drop][]"
value={ef.index}
phx-click={JS.dispatch("change")}
>
<.icon name="hero-x-mark" class="w-6 h-6 relative top-2" />
</button>
</.inputs_for>
<input type="hidden" name="mailing_list[emails_drop][]" />
<button type="button" name="mailing_list[emails_sort][]" value="new" phx-click={JS.dispatch("change")}>
add more
</button>
<:actions>
<.button phx-disable-with="Saving...">Save Mailing list</.button>
</:actions>
</.simple_form>
Unfortunately, trying to run with these changes greets us with the following error condition about column emails
does not exist:
[error] ** (Postgrex.Error) ERROR 42703 (undefined_column) column m0.emails does not exist
That makes sense; we never defined what to do with those embeds. Reading over the documentation a bit, it looks like assigning the values to a jsonb
column type will handle all the serialization logic for us. Given that, we add a new migration:
defmodule DynamicInputs.Repo.Migrations.AddEmails do
use Ecto.Migration
def change do
alter table(:mailing_lists) do
add :emails, :jsonb
end
end
end
This yields us a very ugly but functional modal form that dynamically adds and removes fields.
I am simply not a designer, so I took this heex
, ran to ChatGPT, and said, “Can you make this Phoenix LiveView form more compact, put the name and email pairs on the same line together, and put the ‘x’ at the end of those two? Make the form tighter and look more polished.” It returned heex
that I think lacks a bit of soul but should fit the bill for most people’s needs.
When This Might Be Useful
The documentation example does feel a bit contrived, as one’s first thought might be, “Why not have a recipient table with names, emails, and a many-to-many relationship?” I don’t have an answer there, but I do know that for my scenario, I needed a way to add dynamic attributes to structs, and the names for those attributes vary widely as they’re basically for custom data.
You can see the completed example here which is along with a lot of the small projects I track to learn Elixir and Phoenix deeper. Thankfully, this provided a great native way to solve the interface problem and allowed me to shed all the useless cruft I had built. I hope this turns out useful for a similar situation you may be facing.
Have you encountered similar challenges with dynamic inputs in your applications? How did you solve them? Leave a comment below sharing your experience and any tips you might have for others facing this problem. Your insights could be invaluable to the community!