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
The latest docs no longer use checkboxes, but buttons with LV. The checkboxes are only necessary for usage without LV:
joshua-bouv
-
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
-
(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.
- The schema looks good. You are correct to use the
on_replaceas the docs say. Using a has_many is perfectly fine.
- The changeset looks mostly good, correctly using
cast_assocfor yourroomshas_many, however therequired: trueis not necessary. I don’t think that’s a valid option either(?).
- 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…)
- However, the
nameyou have used for this button’s and hidden inputs is incorrect. It should bename="properties[rooms_drop][]", rather thanname="rooms[rooms_drop][]". Same for sort,name="properties[rooms_sort][]", rather thanname="rooms[rooms_sort][]".
- Finally, moving onto the
add_roombutton. As your ‘room’ is rather unexciting, we can just usesort_param. We would only need to usephx-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 ![]()
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
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.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance










