Hodgson

Hodgson

Managing One-to-Many Relationships in a LiveView Form

Hello Elixir community,

I’m relatively new to Elixir and Phoenix, and I’m looking for guidance in lieu of finding a relevant guide that it still up to date.

I’m seeking guidance and advice on how to insert multiple related records into a database within a single transaction, using data collected from a single form. My data model includes Properties and their associated Rooms, structured in a one-to-many relationship.

Since every property must have at least one room, I’d like to design a form that captures all necessary data for both entities in one go. I am still relatively new to Elixir and haven’t been able to find if this is a recommended approach or any guidance on how I can improve my code.

Here’s the relevant code:

# In lib/my_app/listings/property

schema "properties" do
    field :address, :string
    has_many :rooms, MyApp.Listings.Room

    timestamps(type: :utc_datetime)
end

  @doc false
  def changeset(property, attrs) do
    property
    |> cast(attrs, [:address])
    |> validate_required([:address])
    |> cast_assoc(:rooms, required: true)
  end

# In lib/my_app/listings/room

schema "rooms" do
    field :size, :integer
    belongs_to :property, MyApp.Listings.Property

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(room, attrs) do
    room
    |> cast(attrs, [:size])
    |> validate_required([:size])
  end

# In web/live/listing_live/form.ex

def render(assigns) do
    ~H"""
    <h1>New Property</h1>

    <.simple_form for={@form} id="property-form" phx-submit="save">
      <.input field={@form[:address]} type="text" label="Property Address" />

	<h2>Rooms</h2>
      <.inputs_for :let={room_form} field={@form[:rooms]}>
        <div>
          <.input field={room_form[:size]} type="number" label="Room Size (sq ft)" />
        </div>
      </.inputs_for>

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

  def mount(_params, _session, socket) do
    changeset = Listings.change_property(%Property{})
    {:ok, assign(socket, :form, to_form(changeset))}
  end

  def handle_event("save", %{"property" => params}, socket) do
    case Listings.create_property(params) do
      {:ok, _property} ->
        {:noreply,
         socket
         |> put_flash(:info, "Property created successfully")
         |> redirect(to: ~p"/properties")}

      {:error, changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

Is this the correct way to achieve the goal outlined above, or is there another way that is more aligned with the recommended best practices?

How would I go about updating this to add/remove information about more than one room?

Should I be aiming to use the same form for creating and editing, and what changes would I need to consider making to enable this?

Thank you in advance for your time and expertise!

Most Liked

LostKobrakai

LostKobrakai

The latest docs no longer use checkboxes, but buttons with LV. The checkboxes are only necessary for usage without LV:

joshua-bouv

joshua-bouv

  1. Whilst I’m not sure its required, it doesn’t harm in having the extra hidden input which stores the index. If you ever plan to have the rooms orderable you will need it as it helps ecto identify the row

  2. (and 4.) The following extract from the docs is essentially the butter behind how sort_param works:

:sort_param - the parameter name which keeps a list of indexes to sort from the relation parameters. Unknown indexes are considered to be new entries. Non-listed indexes will come before any sorted ones.

Pressing the button adds the ‘new’ index to the list of rooms. As this is an unknown index, it create a new entry.


I don’t want to give you information overload, but I also want to be some-degree of helpful so ill do my best to be somewhere in the middle.

In regards to your final comment, embedded schemas are useful for if you don’t want to create a table in, lets say postgres.

If you do want to save the value (rooms) as a ‘json blob’ on the row (property), you can use either embed_one or embed_many in combination with an embedded schema.

But the TLDR is that it doesn’t matter if you using an has_many or embed_many, the LV it’s still the same. Still an inputs_for, still calling the changeset on the schema/embedded schema.


Taking a look at your snippet of code, I’ll go through bit by bit.

  1. The schema looks good. You are correct to use the on_replace as the docs say. Using a has_many is perfectly fine.

  1. The changeset looks mostly good, correctly using cast_assoc for your rooms has_many, however the required: true is not necessary. I don’t think that’s a valid option either(?).

  1. Moving onto the render, firstly we have the form.

<.simple_form for={@form} id="property-form" phx-submit="save">

You should add a phx-change="validate" attribute. This will call a handle_event when any input in the form is changed. This is required so you can update the changeset and the form in the socket. Docs

The handle event would look something like this:

def handle_event("validate", %{"properties" => params}, socket) do
  form =
    %Property{}
    |> Property.changeset(params)
    |> to_form(action: :validate)

  {:noreply, assign(socket, form: form)}
end

In this case, we are assuming you are creating a new property so we are creating a fresh %Property{}, and passing in the latest params of the form. action: :validate runs any validations (ie your |> validate_required([:address])). In my example at the bottom of my response, I have included an example of pulling the property from the socket making this compatible with both creating and viewing existing properties.

Adding the attribute above is important, as your buttons call the phx-change attribute. See the below

            <button
                type="button"
                name="rooms[rooms_drop][]"
                value={rf.index}
                phx-click={JS.dispatch("change")}
            >
            </button>

Specifically, phx-click={JS.dispatch("change")}

phx- is automatically pre-pended onto the change, calling the function above you have just created.

(it’s tucked away in the docs admittedly…)


  1. However, the name you have used for this button’s and hidden inputs is incorrect. It should be name="properties[rooms_drop][]", rather than name="rooms[rooms_drop][]". Same for sort, name="properties[rooms_sort][]", rather than name="rooms[rooms_sort][]".

  1. Finally, moving onto the add_room button. As your ‘room’ is rather unexciting, we can just use sort_param. We would only need to use phx-click="add_room" and the corresponding handle_event if you are doing some more complicated things with setting defaults of the room. And in this case, you are (currently) not.

So put that to the back of your head for now, and replace it with this

<button type="button" name="properties[rooms_sort][]" value="new" phx-click={JS.dispatch("change")}>
  Add Room
</button>

You can now delete the def handle_event("add_room", _, socket) do function as you won’t be needing it.


And that’s it, it should now add the room when you hit the button :slight_smile:

Should anyawy…

# In lib/my_app/listings/property
alias MyApp.Listings.Room

schema "properties" do
    field :address, :string
    has_many :rooms, Room, on_replace: :delete

    timestamps(type: :utc_datetime)
end

@doc false
def changeset(property, attrs) do
    property
    |> cast(attrs, [:address])
    |> validate_required([:address])
    |> cast_assoc(:rooms, 
      with: &Room.changeset/2,
      sort_param: :rooms_sort, 
      drop_param: :rooms_drop
    )
end

# In web/live/listing_live/form.ex

def render(assigns) do
    ~H"""
    <h1>New Property</h1>

    <.simple_form for={@form} id="property-form" phx-submit="save" phx-change="validate">
        <.input field={@form[:address]} type="text" label="Property Address" />

        <.inputs_for :let={rf} field={@form[:rooms]}>
            <input type="hidden" name="properties[rooms_sort][]" value={rf.index} />
            <.input type="text" field={rf[:size]} label="Room Size (sq ft)" />
            <button
                type="button"
                name="properties[rooms_drop][]"
                value={rf.index}
                phx-click={JS.dispatch("change")}
            >
            </button>
        </.inputs_for>

        <input type="hidden" name="properties[rooms_drop][]" />

        <button type="button" name="properties[rooms_sort][]" value="new" phx-click={JS.dispatch("change")}>
          Add Room
        </button>

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

def mount(_params, _session, socket) do
    property = %Property{} # in future, you could Repo.get this for when 'viewing' a property
    changeset = Listings.change_property(property)
    {:ok, assign(socket, property: property, form: to_form(changeset))}
end

def handle_event("validate", %{"properties" => params}, socket) do
  form =
    socket.assigns.property
    |> Property.changeset(params)
    |> to_form(action: :validate)

  {:noreply, assign(socket, form: form)}
end

def handle_event("save", %{"property" => params}, socket) do
    case Listings.create_property(params) do
      {:ok, _property} ->
        {:noreply,
         socket
         |> put_flash(:info, "Property created successfully")
         |> redirect(to: ~p"/properties")}

      {:error, changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
end
joshamb

joshamb

Whilst the guide is titled many-to-many, it covers your use case too.

The bits of intrest will be the sort_param and delete_param.

As per the docs, you can use sort_param to add new ‘rooms’ to your property.

Check out the Checkboxes magic section of the fly guide.

Essentially it uses a checkbox disguised as a button to make ecto add a new room to the rooms assoc.

EDIT: See reply below, you can use a button rather than ‘checkbox magic’ - but otherwise its structured the same way


Alternately, you can create a separate button that can manipulate the form (which is needed if you need your new ‘room’ has pre-filled data that is generated on the fly (ie you can’t use default: in the schema). Ie something like the following

handle_event("add_field", _params, socket) do
  update(socket, :form, fn %{source: changeset} -> 
    room = %{address: "123"}

    existing_rooms = get_assoc(changeset, :rooms)
    changeset = put_assoc(changeset, :rooms, [room | existing_rooms])

    to_form(changeset)
  end)
end

An (admittedly now old) guide on this method: One-to-Many LiveView Form | Benjamin Milde

However id suggest if you can use sort_param, do that instead.

Where Next?

Popular in Questions Top

sergio
In Ruby, I can go: User.find_by(email: "foobar@email.com").update(email: "hello@email.com") How can I do something similar in Elixir? ...
New
lessless
I believe there are people here who are dealing with CSV files import on the daily basis, and since Excel is a really popular tool there ...
New
tduccuong
Hi, is there any work on GUI with Elixir, that is similar to Electron/Javascript? My idea is to bundle Phoenix and BEAM into a single se...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
johnnyicon
Hi all, I’ve just started learning Elixir and Phoenix Framework, so please pardon my n00bness at this stage. I’m trying to use Postgres...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
JDanielMartinez
Hi! May someone helps me, please! I have two apps into an umbrella project: the first one is Database, which manages queries, and the se...
New
svb
Hi! Currently I want to submit a form by pressing the Enter key. However, since my input field is of type “textarea” this is just adds a...
New
lanycrost
Hi everyone! I need implement if…else if…else condition from my elixir code, and anymore of this control flow structures not work proper...
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New

Other popular topics Top

siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
gausby
I asked this very same question on twitter and got some interesting feedback, but I thought it would be a good question to ask here as we...
1207 39467 209
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" =&gt; #BSON.ObjectId&lt;58eb1a7a9ad169198c3dXXXX&gt;, "email" =&gt; ...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement