Multiple nested inputs_for doesn't have the expected behavior

I’m trying to create a form where there is two nested associations: the main form (for Registro) has one Termo, and each Termo has many parcelas.

So i added a .inputs_for using the Registro changeset as form and then another .inputs_for that correspond to each individual Parcela, however, nothing is shown, and the changeset is telling that “parcelas can’t be blank”, so checking the form params it isn’t being even sent. What I could be doing wrong?

Here’s the form component template:

defmodule AtivarWeb.SalesLive.FormComponent do
  use AtivarWeb, :live_component

  alias Ativar.Vendas
  alias Ativar.Vendas.Registro

  @impl true
  def render(assigns) do
    ~H"""
    <div class="new-sale-wrapper">
        <div class="new-sale-details-container">
          <.inputs_for :let={termo} field={f[:termo]}>
            <div class="new-sale-details">
              <div class="text-wrapper">Pagamento</div>
              <div class="details">
                <div class="row">
                  <div class="input-data">
                    <.input
                      type="text"
                      field={termo[:descricao]}
                      errors={%{}}
                      label="Descrição do Termo"
                    />
                  </div>
                  <div class="input-data">
                    <.input type="text" field={termo[:valor_total]} errors={%{}} label="Valor Total" />
                  </div>
                  <div class="input-data">
                    <.input
                      type="select"
                      field={termo[:numero_parcelas]}
                      multiple={false}
                      prompt="Selecione"
                      options={Enum.to_list(1..20)}
                      errors={%{}}
                      label="Número de Parcelas"
                    />
                  </div>
                </div>
              </div>
            </div>

            <div class="new-sale-details">
              <div class="text-wrapper">Descrições das Parcelas</div>

              <div class="details">
                <span>HELLLO</span>
                <.inputs_for :let={parcela} field={termo[:parcelas]}>
                  <div class="row" :for={idx <- 1..String.to_integer(termo[:numero_parcelas].value)}>
                    <div class="input-data">
                      <.input
                        type="text"
                        field={parcela[:valor]}
                        errors={%{}}
                        label={"Valor da Parcela #{0}"}
                      />
                    </div>
                    <div class="input-data">
                      <.input
                        type="text"
                        field={parcela[:porcentagem]}
                        errors={%{}}
                        label="Porcentagem da Parcela"
                      />
                    </div>
                    <div class="input-data">
                      <.input
                        type="date"
                        field={parcela[:data_vencimento]}
                        errors={%{}}
                        label="Data de Vencimento"
                      />
                    </div>
                    <div class="input-data">
                      <.input
                        type="text"
                        field={parcela[:comentario]}
                        errors={%{}}
                        label="Comentário"
                      />
                    </div>
                  </div>
                </.inputs_for>
              </div>
            </div>
          </.inputs_for>
          </div>
        </div>
      </.form>
    </div>
    """
  end

  @impl true
  def update(%{sale: sale} = assigns, socket) do
    changeset = Registro.create_changeset(sale, %{})

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)}
  end

  @impl true
  def handle_event("validate", %{"registro" => sale_params}, socket) do
    IO.inspect(sale_params, label: "PARAMS")

    changeset =
      socket.assigns.sale
      |> Registro.create_changeset(sale_params)
      |> IO.inspect(label: "CAST")
      |> Map.put(:action, :validate)

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

  def handle_event("save", %{"registro" => sale_params}, socket) do
    case Vendas.upsert_registro(socket.assigns.sale, sale_params) do
      {:ok, _sale} ->
        {:noreply,
         socket
         |> put_flash(:success, "Venda salvo com sucesso!")
         |> redirect(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    assign(socket, :form, to_form(changeset))
  end
end

Then here’s the Registro’s changeset:

  def create_changeset(registro \\ %Registro{}, attrs) do
    registro
    |> changeset(attrs)
    |> cast_assoc(:carregamento, required: true)
    |> cast_assoc(:termo, required: true)
    |> cast_embed(:cotacao_venda, required: true)
    |> cast_assoc(:transporte, required: true)
    |> validate_required(@required_fields)
    |> foreign_key_constraint(:importador_id)
  end

And here’s the Termo’s changeset:

  def changeset(termo \\ %Termo{}, attrs) do
    IO.inspect(attrs, label: "AQUI")
    termo
    |> cast(attrs, @fields)
    |> cast_assoc(:parcelas, required: true)
    |> maybe_put_valor_total()
    |> foreign_key_constraint(:registro_id)
    |> foreign_key_constraint(:carregamento_id)
  end

those IO.inspect below gives me these results:

PARAMS: %{
  "chegada_aeroporto" => "",
  "cia_frete_exportacao" => "",
  "cliente_importador" => "",
  "cotacao" => "",
  "data_chegada_destino" => "",
  "data_partida" => "",
  "documento_exportador" => "",
  "incoterm" => "",
  "moeda" => "",
  "observacoes_gerais" => "",
  "preco_caixa" => "",
  "produto" => "",
  "quantidade" => "",
  "termo" => %{
    "_persistent_id" => "0",
    "descricao" => "",
    "numero_parcelas" => "3",
    "valor_total" => ""
  },
  "transporte" => %{
    "_persistent_id" => "0",
    "destino" => "",
    "embalagem" => "",
    "transporte_value" => ""
  },
  "valor_negociado" => ""
}
AQUI: %{
  "_persistent_id" => "0",
  "descricao" => "",
  "numero_parcelas" => "3",
  "valor_total" => ""
}
CAST: #Ecto.Changeset<
  action: nil,
  changes: %{
    termo: #Ecto.Changeset<
      action: :insert,
      changes: %{numero_parcelas: 3},
      errors: [parcelas: {"can't be blank", [validation: :required]}],
      data: #Ativar.Pagamentos.Termo<>,
      valid?: false
    >,
    transporte: #Ecto.Changeset<
      action: :insert,
      changes: %{},
      errors: [
        origem: {"can't be blank", [validation: :required]},
        destino: {"can't be blank", [validation: :required]},
        tipo: {"can't be blank", [validation: :required]}
      ],
      data: #Ativar.Logistica.Transporte<>,
      valid?: false
    >
  },
  errors: [
    prazo_chegada: {"can't be blank", [validation: :required]},
    data_partida: {"can't be blank", [validation: :required]},
    data_chegada: {"can't be blank", [validation: :required]},
    incoterm: {"can't be blank", [validation: :required]},
    produto: {"can't be blank", [validation: :required]},
    documento: {"can't be blank", [validation: :required]},
    importador_id: {"can't be blank", [validation: :required]},
    cotacao_venda: {"can't be blank", [validation: :required]},
    carregamento: {"can't be blank", [validation: :required]}
  ],
  data: #Ativar.Vendas.Registro<>,
  valid?: false
>

This seems to be the misunderstanding. <.inputs_for /> already includes a for loop to render the body of the component once per termo[:parcelas] entry if it’s a many style relationship. That however also means it won’t render said body if there are no entries for that.

There’s two options you have here. Either use the append={…} and prepend={…} attributes to append/prepend additional rows to any existing termo[:parcelas] entries or by any means edit termo[:parcelas] directly. E.g. Phoenix.Component — Phoenix LiveView v0.20.14 shows how to use some buttons in combination with a newly added feature in ecto to sort/drop entries, where sort also allows for adding entries.