How to create associated has_many records in LiveView?

Hi all,

I have multiple models which are affected here:

  • customers
  • products
  • orders which belong_to customer and have_many orderlines
  • orderlines which belong_to product and belong_to order

I have a LiveView for orders, which was created via mix phx.gen.live.

The generated template view did only include fields for standard fields like name, which are not associated. What I additional want to do is to create orderlines from within the LiveViews :new and :edit actions.

I tried my way through different places in the docs but was not able to fit everything together:
In the Phoenix.Component docs there is something about embeds_many which I adapted to has_many, so that the example would look as follow:

defmodule PhoenixRise.Orders.Order do
  use Ecto.Schema
  import Ecto.Changeset

  schema "orders" do
    field :name, :string
    field :state, Ecto.Enum, values: [:draft, :cancel, :confirmed, :in_process, :done]
    belongs_to :customer, PhoenixRise.Contacts.Contact

    has_many :orderlines, PhoenixRise.Orders.Orderline, on_replace: :delete
    has_many :products, through: [:orderlines, :product]
    timestamps()
  end

  @doc false
  def changeset(order, attrs) do
    order
    |> cast(attrs, [:name, :state, :customer_id])
    |> cast_assoc(:orderlines,
      sort_param: :orderlines_sort,
      drop_param: :orderlines_drop
    )
    |> validate_required([:name, :state, :customer_id])
    |> unique_constraint(:name)
  end
end

The render-Function for my LiveView FormComponent is as follows:

def render(assigns) do
    ~H"""
    <div>
      <.header>
        <%= @title %>
        <:subtitle>Use this form to manage order records in your database.</:subtitle>
      </.header>

      <.simple_form
        for={@form}
        id="order-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:name]} label="Name" />
        <.input field={@form[:state]} label="State" type="select" options={[{"Draft", "draft"}, {"Confirmed", "confirmed"}]}/>
        <.input field={@form[:customer_id]} label="Customer" />
        <.inputs_for :let={o} field={@form[:orderlines]}>
          <input type="hidden" name="order[orderlines_sort][]" value={o.index} />
          <.input field={o[:product_qty]} placeholder="Quantity" />
          <.input field={o[:product_id]} placeholder="Product" />
          <.input field={o[:product_unit_price]} placeholder="Price" />
          <label>
            <input type="checkbox" name="order[orderlines_drop][]" value={o.index} class="hidden" />
            <.icon name="hero-x-mark" class="w-6 h-6 relative top-2" />
          </label>
        </.inputs_for>
        <label class="block cursor-pointer">
          <input type="checkbox" name="order[orderlines_sort][]" class="hidden" />
          add orderline
        </label>

        <input type="hidden" name="order[orderlines_drop][]" />

        <:actions>
          <.button phx-disable-with="Saving...">Save Order</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

I am aware, that the fields associated by belongs_to have to be filled with the corresponding ids. But that will be the next step. At first, I want to be able to create and delete orderlines from inside the orders form. With the given code, I can create orders without orderlines, but as soon as I add an orderline, the order is just not saved. There is no error message, so I am kind of puzzeling, how to archive this.

Thanks and have a nice weekend,
Michael

Welcome!

Check out Phoenix.Component.inputs_for/1. It takes care of rendering nested form inputs for associations or embeds as well as dynamically adding, removing, and reordering inputs.

2 Likes

Hi and thanks for your answer! This is exactly the part of the documentation I linked and which I tried to adapt. I used inputs_for, but it doesn’t work.

Ahh, I saw embeds_many and made a wrong assumption – whoops!

What does your Orderline changeset function look like? There’s a bit more setup involved when working with associations rather than embeds.

Similar, for sorting, you could do:

%{"name" => "john doe", "addresses" => %{...}, "addresses_sort" => [1, 0]}

And that will internally sort the elements so 1 comes before 0. Note that any index not present in "addressessort" will come _before any of the sorted indexes. If an index is not found, an empty entry is added in its place.

For embeds, this guarantees the embeds will be rewritten in the given order. However, for associations, this is not enough. You will have to add a field :position, :integer to the schema and add a with function of arity 3 to add the position to your children changeset. For example, you could implement:

defp child_changeset(child, _changes, position) do
  child
  |> change(position: position)
end

And by passing it to :with, it will be called with the final position of the item:

changeset
|> cast_assoc(:children, sort_param: ..., with: &child_changeset/3)

source: cast_assoc/3 – Sorting and deleting from -many collections

1 Like

I was able to get one step further and am able to create and delete orderlines from the liveview. My Orderline changeset function does now look as follows:

  def changeset(orderline, attrs) do
    orderline
    |> cast(attrs, [:product_qty, :product_unit_price, :product_id, :order_id])
    |> validate_required([:product_qty, :product_unit_price, :product_id])
  end

Before, I had |> validate_required([:product_qty, :product_unit_price, :product_id, :order_id]), which did not work. I assume, this is because the order_id is not included in the changeset until the data gets commited.

Also, I changed the order`s changeset to:

  def changeset(order, attrs) do
    order
    |> cast(attrs, [:name, :state, :customer_id])
    |> cast_assoc(:orderlines,
      sort_param: :orderlines_sort,
      drop_param: :orderlines_drop,
      with: &PhoenixRise.Orders.Orderline.changeset/2
    )
    |> validate_required([:name, :state, :customer_id])
    |> unique_constraint(:name)
  end