Nested forms using the `table` component from `phx.new`

Hi,

I’m learning Phoenix and I’m currently trying to create a nested form similar to the one in the guide about contexts. So far I have the following schemas:

schema "carts" do
    belongs_to :user, User
    has_many :items, CartItem

    timestamps(type: :utc_datetime)
end

schema "cart_items" do
    field :quantity, :integer
    field :price_when_added, :decimal

    belongs_to :cart, Cart
    belongs_to :product, Product

    timestamps(type: :utc_datetime)
end

What I’m stuck at is creating the nested form so I can update the quantities of many cart items using a single form submission. The example in the guide is using the inputs_for component to iterate over the cart items and display inputs, however, I want to use the table component that is included when generating a new project with phx.new. It takes in a list of rows as an attribute so the inputs_for component doesn’t solve my problem.

Here’s my current template:

<.form :let={f} for={@changeset}>
  <.header>
    Cart

    <:subtitle :if={Enum.empty?(@cart.items)}>Your cart is empty</:subtitle>

    <:actions>
      <.button>Update Cart</.button>
    </:actions>
  </.header>

  
  <.table id="cart_items" rows={how_do_i_get_my_rows_as_fields?}>
    <:col :let={item} label="Name">{item.product.name}</:col>
    <:col :let={item} label="Price">{Decimal.round(item.price_when_added, 2)}</:col>
    <:col :let={item} label="Quantity">{item.quantity}</:col>

    <:action :let={item}>
      <.link href={~p"/cart_items/#{item.product}"} method="DELETE">
        Delete
      </.link>
    </:action>
  </.table>
</.form>

<.back navigate={~p"/products"}>Back to products</.back>

How do I get f[:items] as a list of form fields and use it in place of how_do_i_get_my_rows_as_fields??

<.inputs_for> does it’s own iteration, because it inserts hidden fields per record, so you cannot use it with <.table>, which also iterates rows to handle identification of table rows for LV stream support. These are not easily composable like that. You can manually combine the code powering those two components though. Personally I’d opt for a table component, which works on a lower level and allows the caller to deal with building individual table rows.

1 Like

How I tackled this was moving the <tr> into its own phoenix component (table_row), which will use an inputs_for rather than :for if a %Phoenix.HTML.FormField{} is passed to the rows of the table.

For example:

    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">{gettext("Actions")}</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <.table_row :let={row} rows={@rows} row_id={@row_id}> <%!-- !!!this was modified!!! --%>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </.table_row>
      </tbody>
    </table>
  def table_row(%{rows: %Phoenix.HTML.FormField{}} = assigns) do
    ~H"""
    <.inputs_for :let={row} field={@rows}>
      <tr id={@row_id && @row_id.(row)}>
        {render_slot(@inner_block, row)}
      </tr>
    </.inputs_for>
    """
  end

  def table_row(assigns) do
    ~H"""
    <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
      {render_slot(@inner_block, row)}
    </tr>
    """
  end
1 Like

Thanks for the idea!

I ended up doing something very similar. The only difference is that I used inputs_for and a normal for comprehension so I don’t have to repeat the <tr /> tag twice.

attr :rows, :list, required: true
slot :inner_block

defp table_rows(%{rows: %Phoenix.HTML.FormField{}} = assigns) do
  ~H"""
  <.inputs_for :let={row} field={@rows}>
    {render_slot(@inner_block, row)}
  </.inputs_for>
  """
end

defp table_rows(assigns) do
  ~H"""
  <%= for row <- @rows do %>
    {render_slot(@inner_block, row)}
  <% end %>
  """
end
1 Like