Using Phoenix.HTML.Form for M2M-Relations using a Multiple Select Input in a LiveView

Hey there :slight_smile:

I was trying to adapt the Contexts Article using the Form Struct and to_form.

I ended up having problems to render the input

<.input
      field={@form[:categories]}
      type="select"
      multiple={true}
      options={category_options(@article)}
    />

It errors like this

** (Protocol.UndefinedError) protocol Phoenix.HTML.Safe not implemented for type Ecto.Changeset (a struct).

because the value of the field @form[:categories] contains a Category changeset, introduced by the change_article\2 function - which is taken from the Context Article

  def change_article(%Article{} = article, attrs \\ %{}) do
    categories = list_categories_by_id(attrs["categories"])

    article
    |> Repo.preload(:categories)
    |> Article.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:categories, categories)
  end

Phoenix tries to render the value in the select component but the changeset doesn’t help much here

  def input(%{type: "select"} = assigns) do
    ~H"""
    <fieldset class="fieldset mb-2">
      <label>
        <span :if={@label} class="fieldset-label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={["w-full select", @errors != [] && "select-error"]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </fieldset>
    """
  end

I guess, I understand the problem, but I’m unsure how to proceed from here. I wasn’t very successfull finding examples using to_form.

What should I change, the input template, the form template, the change_article function or something else?

Thank you for help <3

I think the select is supposed to be for @form[:category_ids].

The many_to_many :categories in the schema takes the categories key for itself so you can’t use it.

So would it be best to change the change_article function to populate category_ids?

def change_article(%Article{} = article, attrs \\ %{}) do
   article
   |> Article.changeset(attrs)
   |> Ecto.Changeset.put_change(:category_ids, attrs["category_ids"])
 end

That would make it harder to be re-used in the update and create functions, as I would have to parse the IDs in both of them

def update_article(%Article{} = article, attrs) do
    article
    |> change_article(attrs)
   # put_assoc from list of ids..
    |> Repo.update()
  end

attrs["categories"] there needs to be category_ids, too. list_categories_by_id and put_assoc are doing the work of wiring up the associated data. Read more about it here: Ecto.Changeset — Ecto v3.12.5

Mh yes, but the changeset returned by change_article ends up to build the form using to_form atm:

  defp apply_action(socket, :new, _params) do
    article = %Article{}

    socket
    |> assign(:page_title, "New Article")
    |> assign(:article, article)
    |> assign(:form, to_form(Catalog.change_article(article)))
  end

So category_ids has to be in the changeset (if I’m not missing anything).

But it also might just not work out this way, when m2m relations are involved.

Where would you inject category_ids into fhe form?

When you submit the form, part of the article params will be category_ids. When that goes into the create or update context functions, those will call change_article where it gets passed to list_categories_by_id and the result of that query gets shoved into the changeset by put_assoc.

Ahhh! Now I got it!

<.input
          field={@form[:category_ids]}
          type="select"
          multiple={true}
          options={category_options(@article)}
        />

@form[:category_ids] in this case merely defines the name of the submitted value not the value itself.

The actual submitted value is defined by the options={category_options(@article)} which renders to options.

I was wondering where :category_ids would be populated, as only the changeset with :categories is assigned (as a HTML.Form):

|> assign(:form, to_form(Catalog.change_article(article)))

Thank you very much for clearing things up! :folded_hands:

I think the confusion is coming from the relationship between the Phoenix.HTML.Form struct that comes from the to_form function and what you pass to it(in this case %Article{})?

In here Phoenix.HTML.Form — Phoenix.HTML v4.1.1 there’s this snippet:

It is possible to “access” fields which do not exist in the source data structure. A Phoenix.HTML.FormField struct will be dynamically created with some attributes such as name and id populated.

Yes :slight_smile: I noticed that it’s possible, but I didn’t understand when and how to make use of it.

The frameworks I used so far, expected to have a form data structure completely defined to render it properly and I treated the changeset as such in my mind.

In Phoenix, if you want to sketch out a form quickly, you can use to_form(%{}, as: "some_name"). You can then put whatever inputs to shape the params however you like.

1 Like

I’d suggest reframing “article form setting categories” to “article form managing article_categories”, so moving from many to many to has many relationship. Then you can use inputs_for + select for category_id.

Ups, I somehow missed the notification until now.. :see_no_evil_monkey:

That sounds like good idea, but I struggle to implement it. Can you give me a hint, how the inputs_for template would look like?

Something like that:

<.form for={@articles_form}>
  <.inputs_for :let={inner} field={@articles_form[:article_categories]}>
    <.input type="select" field={inner[:category_id]} options={@categories} />
  </.inputs_for>
</.form>

Doesn’t that create a select input with all categories as choices for each assigned category?

Edit: Ah I see how that’s supposed to work! I would have to implement something like an add / remove functionality to add or remove another select.

Yeah, there’s docs for that on the function documentation for <.inputs_for>, eventually you also want to avoid allowing duplicate category selection. But that doesn’t concern the form input anymore. It’s all just presentation logic and constraints.