Understanding Phoenix.HTML.Form, Changesets and data lifecycle

Hello everyone,

I’ve been writing an admin area in Phoenix LiveView to learn it and admin things. If I’m honest it’s been both amazing and quite frustrating, probably because my mental model is wrong.

I am thinking that my changeset or form is all of the data and information I need to be able to display my form.

Inside it I have a list of events and have managed despite many other examples that seem to duplicate the data for their own purposes I simply have a changeset and I’m making changes to it such as appending %Events{} to the association with put_assoc and get_assoc. It was really painful finding the incantations for doing this but I eventually got to this:

  def handle_event("add_class_event", params, socket) do
    changeset =
      socket.assigns.changeset
      |> Ecto.Changeset.put_assoc(
        :events,
        Ecto.Changeset.get_assoc(socket.assigns.changeset, :events) ++
          [
            %ClassEvent{}
          ]
      )
    {:noreply, assign(socket, :changeset, changeset)}
  end

Okay great, so the next thing I started thinking was, okay each event has some very custom attributes/subforms that I will need to reveal when the “type” select is changed. Fine we can add phx-changed=“type-dropdown” then handle this event in out live view! We should simply just be able to update our changeset and the form should reflect this and change based on that data. It does not.

  def handle_event(
        "type-dropdown",
        %{"class" => %{"events" => changed}} = params,
        socket
      ) do
    index = changed |> Map.keys() |> Enum.at(0) |> String.to_integer()
    value = changed |> Map.values() |> Enum.at(0) |> Map.get("type")

    class_events = Ecto.Changeset.get_assoc(socket.assigns.changeset, :events)

    updated_class_event =
      class_events |> Enum.at(index) |> Ecto.Changeset.put_change(:type, value) |> IO.inspect()

    new_class_events = List.replace_at(class_events, index, updated_class_event)

    changeset =
      socket.assigns.changeset
      |> Ecto.Changeset.put_assoc(
        :events,
        new_class_events
      )

    {:noreply, socket |> assign(assigns: %{changeset: changeset})}
  end

Firstly this seems like a bit of a mess to be able to change one value in the form and secondarily it does not work. Is there something about changesets, forms and live view that I am misunderstanding? Maybe you are all going to tell me just add a list for the event-type dropdown values and have that be the source of truth, it just seems to go against how I would model this in react and changesets seem like such a clean pattern it seems odd if I can’t keep all my data in one place while editing my form.

3 Likes

It seems like what you’re struggling with is nested inputs in forms. Dynamically adding and removing nested inputs was a known pain point that was very recently addressed in the latest version of LiveView v0.19 released just last month. I’d also suggest taking a look at the open sourced trello-esque TodoTrek repo for examples.

Dynamic forms with new inputs_for

Dynamicaly adding and removing inputs with inputs_for is now supported by rendering checkboxes for inserts and removals. Libraries such as Ecto, or custom param filtering, can inspect the paramters and handle the added or removed fields.

source: Phoenix LiveView 0.19 released - Phoenix Blog

Regarding the custom attributes/subforms, could you give a few more details of what you’re trying to achieve? And what your data model looks like? You shouldn’t need to manually update the changeset like that in the "type-dropdown" event callback.

I’m just speculating here, but if it’s something along the lines of a parent ClassCalendar that has_many ClassEvents and then each nested ClassEvent has a :type attribute that affects which other attributes gets shown e.g. if ClassEvent has type: "field trip" than show attribute input field for :destination.

# nested within the `ClassCalendar` parent form component
<.inputs_for :let={event_form} field={@form[:class_events]}>
  <.input type="select" field={event_form[:type]} placeholder="event type dropdown" />
  <.input :if={event_form[:type] in ["field trip"]} type="text" field={event_form[:destination]} placeholder="destination input conditionally displayed" />
</.inputs_for>

<label class="block cursor-pointer">
  <input type="checkbox" name="list[emails_sort][]" class="hidden" />
  add more
</label>
3 Likes

Thanks for your reply, just for context you can see what I’m building here: https://veloa.co

Here is the inputs_for section of my code:

      <%= for {class_event, index} <- Enum.with_index(inputs_for(@form, :events)) do %>
      <div id={"item-#{index}"}>
        <hr />
        <div class="row">
          <div class="col-md-3">
            <.input type="select" phx-change={"type-dropdown"} label="Event type" options={["Resistance": "resistance", "Music": "music", "Segment": "segment", "Set": "set", "Cadence": "cadence", "Overlay Text": "overlay_text"]} field={class_event[:type]} />
          </div>
          <div class="col-md-3">
            <.input type="select" label="Resistance type" options={["resistance", "hill", "constant_power"]} field={class_event[:resistance_type]} />
          </div>
          <div class="col-md-3">
            <.input type="number" label="From Start (ms)" field={class_event[:start_time]} />
          </div>
          <div class="col-md-3">
            <.input type="number" label="End At (ms)" field={class_event[:end_time]} />
          </div>
        </div>
        <div class="row">
          <.input type="textarea" label="Notes" field={class_event[:notes]} />
        </div>

        <div class="row">
          <%= inspect(class_event[:type]) %>
          <div :if={class_event[:type] in ["music"]}>Music</div>
        </div>

      </div>
      <% end %>

When I select from the dropdown the event is fired and if I update the changeset to “music”, it should both change the output of the inspect statement at the bottom and add the

that says Music based on the :if condition. It seems selecting the dropdown does not cause a DOM update for some reason which I find a bit strange. Is setting the chageset correct or do I need to do something else?

Like I mentioned earlier, you shouldn’t need a separate phx-change="type-dropdown" at the input level and its corresponding handle_event/3. Instead, the parent form phx-change="validate" and its corresponding handle_event/3 should be sufficient in updating the parent :form which will then show/hide something within the same form.

Note that by specifying phx-change at the input level, the phx-change at the form level no longer gets called i.e. LiveView will no longer push a "validate" event and instead only push "type-dropdown" event. It’s not in addition to, but instead of.

Also, you can get the index off of the nested form struct as discussed in this post Inputs_for/4 collection indices - #6 by silverdr instead of using Enum.with_index.

Okay fine, but I’ve literally tried everything I can think of to change the value, adding events, removing things, moving things to a completely flat level (no live components anything) and event testing at a higher level in the form right inside the live view (shown below). I’ve even upgraded live view to 0.19.2.

Not that these are my duration values but should me selecting the dropdown lead to the <%= input_value(@form, :duration) %> changing or is there something I’m missing?

          <div class="col-md-6 mb-3">
            <.input type="select" options={[1000, 2000, 3600, 3601, 4000, 5000]} label="Duration" field={@form[:duration]} />

            <%= input_value(@form, :duration) %>
          </div>

I’m basically at the point where I bin LiveView and install nextjs because it actually works for basic changes like this.

For completeness here is my validate function:

  def handle_event("validate", %{"class" => class_params} = params, socket) do
    IO.inspect(params)

    changeset =
      %Class{}
      |> Cycling.change_class(params)
      |> Map.put(:action, :insert)

    {:noreply, socket |> assign(assigns: %{changeset: changeset})}
  end

I’m seeing params change with the correct values when the select value is changed.

Okay it finally works! I needed to change my validate function to actually work.

  def handle_event("validate", %{"class" => class_params} = params, socket) do
    changeset =
      %Class{}
      |> Cycling.change_class(class_params)

    {:noreply, socket |> assign(%{changeset: changeset})}
  end

Thanks for your help on this!

1 Like

Glad you got it working!

Here’s a minimal example repo that works off of the Phoenix.HTML.Form struct rather than the changeset since

Ecto changesets are meant to be single use. By never storing the changeset in the assign, you will be less tempted to use it across operations
source: Phoenix.Component.form/1

It just required accessing the params key of the nested form like so:

2 Likes