Handling {:array, string} forms with phoenix for create/edit form component

tl;dr: I want to know the most phoenix way of letting users fill a {:array, :string} input via live view form component.

  defp deps do
    [
      {:argon2_elixir, "~> 2.0"},
      {:phoenix, "~> 1.6.0"},
      {:phoenix_ecto, "~> 4.4"},
      {:ecto_sql, "~> 3.6"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 3.0"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_view, "~> 0.16.0"},
    ]
  end

inputs

I’m currently experimenting with the current schema and the relevant prop is :synonyms with {:array, :string} type. The user should be able to write any amount of items there.

defmodule MyApp.MyContext.Tag do
  use Ecto.Schema
  import Ecto.Changeset

  schema "tags" do
    field :synonyms, {:array, :string}
    timestamps()
  end

  @doc false
  def changeset(tag, attrs) do
    tag
    |> cast(attrs, [:synonyms])
  end
end

I was able to make both edit and create work with some assignments called @synonyms and dynamically rendering inputs called name="tag[synonyms][]" using the code below:

defmodule MyAppWeb.TagLive.FormComponent do
  use MyAppWeb, :live_component

  alias MyApp.MyContext

  @impl true
  def update(%{tag: tag} = assigns, socket) do
    changeset = MyContext.change_tag(tag)

    {:ok,
     socket
     |> assign(assigns)
     |> assign(:changeset, changeset)
     |> assign(:synonyms, tag.synonyms)} # <---- relevant
  end

  @impl true
  def handle_event("validate", %{"tag" => tag_params}, socket) do  # <---- no changes needed
    # tag_params => %{ "synonyms: ["foo", "bar"], rest... }, which is ok :)
    changeset =
      socket.assigns.tag
      |> Jobs.change_tag(tag_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :changeset, changeset)}
  end

  # unrelated ...
end
  <.form
    let={f}
    for={@changeset}
    id="tag-form"
    phx-target={@myself}
    phx-change="validate"
    phx-submit="save">
  
    <%= label f, :slug %>
    <%= text_input f, :slug %>
    <%= error_tag f, :slug %>
  
    <%= label f, :type %>
    <%= select f, :type, Ecto.Enum.values(MyApp.MyContext.Tag, :type), prompt: "Choose a value" %>
    <%= error_tag f, :type %>

    <%= for s <- @synonyms do %>  <---------------- here
      <%= label f, :slug %>
      <input name="tag[synonyms][]" value={s}>
      <%= error_tag f, :slug %>
    <% end %>  
  
    <div>
      <%= submit "Save", phx_disable_with: "Saving..." %>
    </div>
  </.form>

From there I should also make “add more” and “remove” inputs which is fine but I was left wondering if I’ve missed something obvious on the docs or somewhere else that I wasn’t able to find by my searches (as most results seem to deal with embedded schemas instead of an array on the very own schema)

4 Likes

did u solve this

if so could u let me know

It’s been a while so I’m not certain but I think I just went ahead and used the things described on the original topic above

Hello! I had the exact same requirement and a lot of troubles but I got it to work now with the new versions of stuff. Idk if idiomatic, I’m new here :smiley:

So basically I have a similar Ecto schema with an array of strings as features that I want to edit in a form component:

schema "products" do
    ...
    field :features, {:array, :string}, default: [""]
  end

In my form live component I used the same trick as presented earlier here from couple years ago, with the product[features][] syntax in the name property for the input they get mapped correctly to this array.

<%= for {entry, index} <- Enum.with_index(Ecto.Changeset.get_field(@form.source, :features, [])) do %>
          <div class="flex items-center gap-2">
            <.input value={entry} name="product[features][]" type="text" label="Feature" />
            <div phx-click="remove_feature" phx-value-index={index} phx-target={@myself}>
              <.icon
                name="hero-x-mark"
                class="w-8 h-8 relative top-4 bg-red-500 hover:bg-red-700 hover:cursor-pointer"
              />
            </div>
          </div>
        <% end %>

It was a bit tricky getting the index so I wrapped it into an Enum.with_index to make the deletion mutation easier to manage.

Here are my working but a bit ugly? click handlers as well:

  def handle_event("add_feature", _, socket) do
    existing_features = Ecto.Changeset.get_field(socket.assigns.form.source, :features, [])

    append_feature =
      Catalog.change_product(socket.assigns.product, %{
        "features" => existing_features ++ [""]
      })

    {:noreply, assign(socket, form: to_form(append_feature))}
  end

  def handle_event("remove_feature", %{"index" => index}, socket) do
    updated_features =
      Ecto.Changeset.get_field(socket.assigns.form.source, :features, [])
      |> List.delete_at(String.to_integer(index))

    remove_feature =
      Catalog.change_product(socket.assigns.product, %{
        "features" => updated_features
      })

    {:noreply, assign(socket, form: to_form(remove_feature))}
  end

Is this maybe better to do in an embedded schema? Then I guess I could use inputs_for or something. Maybe that is the way!

2 Likes

Very insightful thread for me :slight_smile:

Is the name="map_name[list_name][]" behavior documented anywhere? It looks very strange to me since you could assume it’s a simple HTML name property without any template magic…