How to wire up a Phoenix form for a field {:array, :string}?

My schema is very simple:

  schema "partnerships" do
    field :email, :string
    field :name, :string
    field :social_media_urls, {:array, :string}

    timestamps()
  end

image

I want to be able to click on Add Another to add another input to the UI.

Here’s what I’ve tried so far:

def mount(_params, _session, socket) do
  roles = Partnerships.list_partnership_roles()
  changeset = make_changeset()

  socket =
    socket
    |> assign(%{
      page_title: "Partnerships",
      roles: Enum.map(roles, &{&1.name, &1.id}),
      changeset: changeset,
      social_media_urls: changeset.data.social_media_urls
    })

  {:ok, socket}
end

@impl true
def handle_event(
      "validate",
      %{"partnership" => attrs},
      socket
    ) do
  changeset =
    %Partnership{}
    |> Partnerships.change_partnership(attrs)
    |> Map.put(:action, :validate)

  {:noreply,
    assign(socket,
      changeset: changeset,
      social_media_urls: changeset.data.social_media_urls || [""]
    )}
end

def handle_event("add-new-social-media-urls", attrs, socket) do
  social_media_urls = ["" | socket.assigns.social_media_urls]

  changeset =
    socket.assigns.changeset
    |> Ecto.Changeset.change(social_media_urls: social_media_urls)

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

defp make_changeset(attrs \\ %{}) do
    %Partnership{
      social_media_urls: [""]
    }
    |> Partnerships.change_partnership(attrs)
  end

The problem is that as soon as validate runs it wipes out my social media url fields.

How would you wire up this form?

3 Likes

So here’s how you do it properly.

Create your changeset for the form as usual.

  def mount(_params, _session, socket) do
    changeset = make_changeset()

    socket =
      socket
      |> assign(%{
        changeset: changeset
      })

    {:ok, socket}
  end

Then in your form, you need to use get_field to retrieve that array of values to tie it in properly to the schema.

<div class="form-group">
  <.form_label
    form={f}
    field={:social_media_urls}
    label="Social Media Profiles"
    class="mb-0 text-sm text-white"
  />
  <%= for social_media_url <- Ecto.Changeset.get_field(@changeset, :social_media_urls, [""]) do %>
    <%= text_input(f, :favorite_games,
      name: "partnership[social_media_urls][]",
      value: social_media_url,
      class: "form-input mb-2",
      placeholder: "e.g. twitter.com/grilla"
    ) %>
  <% end %>
  <.form_feedback form={f} field={:social_media_urls} />
</div>

Take special note of the name value. That’s what allows phoenix to do it’s binding magic to the right schema fields

You end up with a proper filled in Elixir map ready to persist to your database.

One more big detail is in your schema cast, you should use empty_values: [].

  def changeset(partnership, attrs) do
    partnership
    |> cast(attrs, [:name, :email, :about, :partnership_role_id])
    |> cast(
      attrs,
      [:social_media_urls],
      empty_values: []
    )
    |> validate_required([:name, :email, :about, :partnership_role_id, :social_media_urls])
    |> Emails.Validation.validate_email_format()
  end
3 Likes

You could try using existing changeset from assigns instead of building a new one in validate handler.

1 Like

I have a similar setup. “validate” still erases my values. Anything that I am doing wrong?

<%= for service <- Ecto.Changeset.get_field(fa.source, :additional_services, [""]) do %>
              <span class="inline-flex flex-wrap pl-4 pr-2 py-2 m-1 justify-between items-center text-sm font-medium rounded-xl cursor-pointer bg-purple-500 text-gray-200 hover:bg-purple-600 hover:text-gray-100 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-800 dark:hover:text-gray-100 max-w-md">
                <%= service %>
                <a
                  href="#"
                  class="text-white hover:text-white"
                  phx-target={@myself}
                  phx-click="delete_warehouse_service"
                  phx-value-tagging={service}
                  phx-value-index={fa.index}
                >
                  <span class="material-icons text-base px-2">delete</span>
                </a>
              </span>
              <%= hidden_input(fa, :additional_services, name: "warehouse[additional_services][]", value: service) %>
            <% end %>

Changeset

@spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t()
  def changeset(%__MODULE__{} = warehouse, attrs) when is_map(attrs) do
    changeset =
      warehouse
      |> cast(attrs, @warehouse_fields)
      |> cast(
        attrs,
        [:additional_services],
        empty_values: []
      )
1 Like