We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Post 81
Ash Framework 10 - Nested Forms
Published on: 2025-10-17
Tags:
elixir, Blog, Side Project, LiveView, Ecto, Html/CSS, Phoenix, Ash, Framework
Chapter 8: Having Fun With Nested Forms (179)
Setting Up a Track Resource
Now we can setup a new resource so we can have tracks for the albums. We want to have the following for this new resource:
Order of the tracks
Name of the track
Duration of the track
What album the tracks belong to
(Id and timestamp for records)
The standard console command is going to be used here.
mix ash.gen.resource Tunez.Music.Track --extend postgres
Once that is done we can head to lib/tunez/music/track.ex and add in the new resource attributes.
defmodule Tunez.Music.Track do
# ...
attributes do
uuid_primary_key(:id)
attribute :order, :integer do
allow_nil?(false)
end
attribute :name, :string do
allow_nil?(false)
end
attribute :duration_seconds, :integer do
allow_nil?(false)
constraints(min: 1)
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end
relationships do
belongs_to :album, Tunez.Music.Album do
allow_nil?(false)
end
end
end
Order will be and integer that will be 1 to the last track number that will help to order the tracks. We now need to head to the album domain to set-up the relationship lib/tunez/music/album.ex
relationships do
# ...
has_many :tracks, Tunez.Music.Track do
sort(order: :asc)
end
end
As we can see again there is some ASH functionality that can be sure to order the tracks in the right way. Last thing before the codegen and migration we want to add in the relation to the artist as well. lib/tunez/music/track.ex
postgres do
# ...
references do
reference(:album, index?: true, on_delete: :delete)
end
end
Let's start the codegen and then migrate
mix ash.codegen add_album_tracks
mix ash.migrate
Reading and Writing Track Data
We want to be able to add and delete tracks with a form here, but we also want it to be part of the album form as well. Let's start out with the actions that we will need. lib/tunez/music/track.ex
actions do
defaults([:read, :destroy])
create :create do
primary?(true)
accept([:order, :name, :duration_seconds, :album_id])
end
update :update do
primary?(true)
accept([:order, :name, :duration_seconds])
end
end
Managing Relationships for Related Resources
Let's now head to the form for albums and add in a form for the album. lib/tunez_web/albums/form_live.ex
<.input field={form[:cover_image_url]} label="Cover Image URL" />
<.track_inputs form={form} />
<:actions>
That is not all we need we have a error when we try to create a new album. This is the error message that we got.
tracks at path [] must be configured in the form to be used with `inputs_for`. For example:
There is a relationship called `tracks` on the resource `Tunez.Music.Album`.
Perhaps you are missing an argument with `change manage_relationship` in the action Tunez.Music.Album.create?
Managing Relationships with... err... manage_relationship
This might be the most important part of Ash. Trying to deal with relationship data in action will probably be using this function. This will be put into the action block of the resource.
"Using Type append_and_remove"
append_and_remove is a way of saying "replace the existing links in the relationship with these new links, adding and removing records where necessary." Lets say that we wanted to add in the artist id to the album that we are creating we could do something like.
create :create do
accept [:name, :year_released, :cover_image_url]
argument :artist_id, :uuid, allow_nil?: false
change manage_relationship(:artist_id, :artist, type: :append_and_remove)
end
Using Type direct_control
direct_control will help us to not only unlink but delete the entry. Let's combine the 2 and then make sure that we are doing everything that we want when we are creating a new album. lib/tunez/music/album.ex
create :create do
accept([:name, :year_released, :cover_image_url, :artist_id])
argument(:tracks, {:array, :map})
change(manage_relationship(:tracks, type: :direct_control))
end
update :update do
accept([:name, :year_released, :cover_image_url])
require_atomic? false
argument(:tracks, {:array, :map})
change(manage_relationship(:tracks, type: :direct_control))
end
end
That was it we can now create a new album with tracks and add them as we go or with a batch with the right map.
ex(1)> tracks = [
%{order: 1, name: "Test Track 1", duration_seconds: 120},
%{order: 3, name: "Test Track 3", duration_seconds: 150},
%{order: 2, name: "Test Track 2", duration_seconds: 55}
]
[...]
iex(2)> Tunez.Music.create_album!(%{name: "Test Album", artist_id: «uuid»,
year_released: 2025, tracks: tracks}, authorize?: false)
«SQL queries to create the album and each of the tracks»
#Tunez.Music.Album<
tracks: [
#Tunez.Music.Track<order: 1, ...>
#Tunez.Music.Track<order: 2, ...>,
#Tunez.Music.Track<order: 3, ...>
],
name: "Test Album", ...
>
Now that that is done we need to be able to load the track data in the form to make sure that we have the ones already as well as show the ones we are adding. head to lib/tunez_web/live/album/form_live.ex
def mount(%{"id" => album_id}, _session, socket) do
album =
Tunez.Music.get_album_by_id!(album_id,
load: [:artist, :tracks],
actor: socket.assigns.current_user
)
Adding and Removing Tracks via the Form
Ash created all the buttons we need to add and remove the tracks but the buttons don't do anything but try to trigger the event "add-track" or "remove-track" let's implement those now.
"Adding New Rows for Track Data"
AshPhoenix.form has some functionality for this in the form of add_form and remove_form. Staying in the same file but heading to the handle_event("add_track")
def handle_event("add-track", _params, socket) do
socket =
update(socket, :form, fn form ->
AshPhoenix.Form.add_form(form, :tracks)
end)
{:noreply, socket}
end
This is the basic way to do the add tracks but if we are updating a track we would want the tracks to show up in the right order so here is the way we aer going to do it in our app.
update(socket, :form, fn form ->
order = length(AshPhoenix.Form.value(form[:tracks]) || []) + 1
AshPhoenix.Form.add_form(form, :tracks, params: %{"order" => order})
end)
"Removing Existing Rows of Track Data"
Now we want to deal with the "remove-track" event. If you look at the track_inputs section we can see that it again calls the "remove-track" event let's head to the handle_event for that.
def handle_event("remove-track", %{"path" => path}, socket) do
socket =
update(socket, :form, fn form ->
AshPhoenix.Form.remove_form(form, path)
end)
{:noreply, socket}
end
What About Polices?
Right now we are still secure as any form is still related to the form that called it. So you cant create a album without the right perms, but we might want to add in other policies going forward. Let's head to the track resource and add some policies now. lib/tunez/music/track.ex
defmodule Tunez.Music.Track do
use Ash.Resource,
otp_app: :tunez,
domain: Tunez.Music,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
polices do
policy always() do
authorize_if(accessing_from(Tunez.Music.Album, :tracks))
end
end
This can be read as if tracks are being (action) through the :tracks relationship through Album you are fine. This created a lot of way to access the tracks but one that is missing is the fetching a track by its ID. That as that is just using the track data not through anything else. Let's add that now. lib/tunez/music/track.ex
polices do
policy always() do
authorize_if(accessing_from(Tunez.Music.Album, :tracks))
authorize_if(action_type(:read))
end
end
Is that simple we now have a way to access the data without needing to go through the right channels.
Reorder All of the Tracks!!!
Right now we have the place holder "Track Data Coming soon..." Let's deal with that. Head to lib/tunez_web/live/artists/show_live.ex
def handle_params(%{"id" => artist_id}, _url, socket) do
artist =
Tunez.Music.get_artist_by_id!(artist_id,
load: [albums: [:tracks]],
actor: socket.assigns.current_user
)
# ...
</.header>
<.track_details tracks={@album.tracks} />
</div>
</div>
Okay that is set but we want to make it look better.
Automatic Track Numbering
This is done pretty easily with some changes to the actions for the album. Still using the manage_relationship we need to add an extra param. lib/tunez/music/album.ex
create :create do
accept([:name, :year_released, :cover_image_url, :artist_id])
argument(:tracks, {:array, :map})
change(manage_relationship(:tracks, type: :direct_control), order_is_key: :order)
end
update :update do
accept([:name, :year_released, :cover_image_url])
require_atomic? false
argument(:tracks, {:array, :map})
change(manage_relationship(:tracks, type: :direct_control), order_is_key: :order)
end
Then we need to remove the order from the form as we don't want them to change the order willy nilly. lib/tunez_web/live/albums/form_live.ex
<tr data-id={track_form.index}>
<td class="px-3 w-20">
<%!-- <.input field={track_form[:order]} type="number" /> --%>
</td>
Ordering Numbering, What's the Difference?
First off lets add in a calculation that will give us the order in a way that people understand (not starting at 0) lib/tunez/music/track.ex
calculations do
calculate :number, :integer, expr(order + 1)
end
Now that we have the calculation we want to always load this whenever we look at the track information. To do that we need to add in a preparation.
preparations do
prepare build(load: [:number])
end
Now let's leverage that to output the right info in the page. lib/tunez_web/live/artists/show_live.ex
<tr :for={track <- @tracks} class="border-t first:border-0 border-gray-100">
<th class="whitespace-nowrap w-1 p-3">
{String.pad_leading("#{track.number}", 2, "0")}.
</th>
Okay so we are all set on that front now we can seed some tracks in the world.
mix run priv/repo/seeds/08-tracks.exs
That will run just the tracks but we can also add in the tracks to the mix seed command but uncommenting the lines the the mix.exs
defp aliases do
[
setup: ["deps.get", "ash.setup", "assets.setup", "assets.build", "run priv/repo/seeds.exs"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
seed: [
"run priv/repo/seeds/01-artists.exs",
"run priv/repo/seeds/02-albums.exs",
"run priv/repo/seeds/08-tracks.exs"
],
]
Drag n' Drop Sorting Goodness
We want to add the ability to drag and drop the tracks to get a new order. We will do this by JSHooks
"Integrating a SortableJS Hook"
We are now going to add in a draggable hooks to the order
lib/tunez_web/albums/form_live.ex
<tbody phx-hook="trackSort" id="trackSort">
<.inputs_for :let={track_form} field={@form[:tracks]}>
<tr data-id={track_form.index}>
<td class="px-3 w-20">
<span class="hero-bars-3 handle cursor-pointer" />
</td>
Now that we have that we need to deal with the "reorder-tracks" event.
def handle_event("reorder-tracks", %{"order" => order}, socket) do
socket =
update(socket, :form, fn form ->
AshPhoenix.Form.sort_forms(form, [:tracks], order)
end)
{:noreply, socket}
end
Automatic Conversions Between Seconds and Minutes
Let's make sure that the track duration is no longer the amount of seconds but the minutes and seconds.
Calculating the Minutes and Seconds of a Track
Okay so for this we are going to use some calculations but in order to do this we can then create our own module that will deal with the conversion. lib/tunez/music/calculations/seconds_to_minutes.ex
defmodule Tunez.Music.Calculations.SecondsToMinutes do
use Ash.Resource.Calculation
@impl true
def calculate(tracks, _opts, _context) do
Enum.map(tracks, fn %{duration_seconds: duration} ->
seconds =
rem(duration, 60)
|> Integer.to_string()
|> String.pad_leading(2, "0")
"#{div(duration, 60)}:#{seconds}"
end)
end
end
With that done let's leverage that to make a calculation to covert the data. lib/tunez/music/track/ex
calculations do
calculate :number, :integer, expr(order + 1)
calculate :duration, :string, Tunez.Music.Calculations.SecondsToMinutes
end
"Two Implementations for Every Calculation"
Any calculation can be run either in the database or in code (second).
iex(1)> Tunez.Music.get_album_by_id!(«uuid», load: [:description])
SELECT a0."id", «the other album fields», (a0."name"::text || ($1 ||
a0."year_released"::bigint))::text FROM "albums" AS a0 WHERE (a0."id"::uuid
= $2::uuid) [" :: ", «uuid»]
%Tunez.Music.Album{description: "Chronicles :: 2022", ...}
In Memory
iex(2)> album = Tunez.Music.get_album_by_id!(«uuid»)
SELECT a0."id", a0."name", a0."cover_image_url", a0."created_by_id", ...
%Tunez.Music.Album{description: #Ash.NotLoaded<...>, ...}
iex(3)> Ash.load!(album, :description, reuse_values?: true)
%Tunez.Music.Album{description: "Chronicles :: 2022", ...}
For our data we want to use the database to do this with an expression. That way the database can do that for us. Head back to lib/tunez/music/calculations/seconds_to_minutes.ex
defmodule Tunez.Music.Calculations.SecondsToMinutes do
use Ash.Resource.Calculation
@impl true
def expression(_opts, _context) do
expr(
fragment("? / 60 || to_char(? * interval '1s', ':SS')", duration_seconds, duration_seconds)
)
end
end
Let's test this in iex
iex(7)> Ash.get!(Tunez.Music.Track, «uuid», load: [:duration])
SELECT t0."id", t0."name", t0."order", t0."inserted_at", t0."updated_at",
t0."duration_seconds", t0."album_id", (t0."order"::bigint + $1::bigint)
::bigint, (t0."duration_seconds"::bigint / 60 || to_char(t0."duration_seconds"
::bigint * interval '1s', ':SS'))::text FROM "tracks" AS t0 WHERE
(t0."id"::uuid = $2::uuid) LIMIT $3 [1, «uuid», 2]
#Tunez.Music.Track<duration: "5:04", duration_seconds: 304, ...>
Updating the Track List with Formatted Durations
Now that we have the ability to format and the durations we can now leverage Aggregates to total the album duration and then use the new calculation to show the track length as well. Start at lib/tunez/music/album.ex
calculations do
# ...
calculate :duration, :string, Tunez.Music.Calculations.SecondsToMinutes
end
aggregates do
sum :duration_seconds, :tracks, :duration_seconds
end
Now head to lib/tunez/music/track.ex
preparations do
prepare build(load: [:number, :duration])
end
Now let's head to lib/tunez_web/live/artists/show_live.ex
def handle_params(%{"id" => artist_id}, _url, socket) do
artist =
Tunez.Music.get_artist_by_id!(artist_id,
load: [albums: [:duration, :tracks]],
actor: socket.assigns.current_user
)
<.h2>
{@album.name} ({@album.year_released})
<span :if={@album.duration} class="text-base"> ({@album.duration})</span>
</.h2>
<tr :for={track <- @tracks} class="border-t first:border-0 border-gray-100">
<th class="whitespace-nowrap w-1 p-3">
{String.pad_leading("#{track.number}", 2, "0")}.
</th>
<td class="p-3">{track.name}</td>
<td class="whitespace-nowrap w-1 text-right p-2">{track.duration}</td>
</tr>
Now it looks great but now we want to be able to let our users enter in human readable values into the track info.
Calculating the Seconds of a Track
Right now we can only accept the track length as seconds but we want to be able to enter in the minutes:seconds format and then turn it into seconds. lib/tunes/music/track.ex
actions do
defaults([:read, :destroy])
create :create do
primary?(true)
accept([:order, :name, :album_id])
argument(:duration, :string, allow_nil?: false)
change(Tunez.Music.Changes.MinutesToSeconds, only_when_valid?: true)
end
update :update do
primary?(true)
accept([:order, :name])
require_atomic? false
argument(:duration, :string, allow_nil?: false)
change(Tunez.Music.Changes.MinutesToSeconds, only_when_valid?: true)
end
end
Okay now that we have this we need to create that module. lib/tunez/music/changes/ minutes_to_seconds.ex
defmodule Tunez.Music.Changes.MinutesToSeconds do
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
{:ok, duration} = Ash.Changeset.fetch_argument(changeset, :duration)
with :ok <- ensure_valid_format(duration),
:ok <- ensure_valid_value(duration) do
changeset
|> Ash.Changeset.change_attribute(:duration_seconds, to_seconds(duration))
else
{:error, :format} ->
Ash.Changeset.add_error(changeset,
field: :duration,
message: "use MM:SS format"
)
{:error, :value} ->
Ash.Changeset.add_error(changeset,
field: :duration,
message: "must be at least 1 second long"
)
end
end
defp ensure_valid_format(duration) do
if String.match?(duration, ~r/^\d+:\d{2}$/) do
:ok
else
{:error, :format}
end
end
defp ensure_valid_value(v) when v in ["0:00", "00:00"], do: {:error, :value}
defp ensure_valid_value(_value), do: :ok
defp to_seconds(duration) do
[minutes, seconds] = String.split(duration, ":", parts: 2)
String.to_integer(minutes) * 60 + String.to_integer(seconds)
end
end
We can test it in iex
ex(4)> Tunez.Music.Track
Tunez.Music.Track
iex(5)> |> Ash.Changeset.for_create(:create, %{duration: "02:12"})
#Ash.Changeset<
attributes: %{duration_seconds: 132},
arguments: %{duration: "02:12"},
...
Now let's leverage that new way of doing things lib/tunez_web/live/form_live.ex
<tr data-id={track_form.index}>
<td class="px-3 w-20">
<span class="hero-bars-3 handle cursor-pointer" />
</td>
<td class="px-3">
<label for={track_form[:name].id} class="hidden">Name</label>
<.input field={track_form[:name]} />
</td>
<td class="px-3 w-36">
<label for={track_form[:duration].id} class="hidden">Duration</label>
<.input field={track_form[:duration]} />
</td>
Wow we are done with that now.
Adding Track Data to API Responses
Okay so now we can start to leverage the track data to the API responses. First let's add in the extends
mix ash.extend Tunez.Music.Track json_api
mix ash.extend Tunez.Music.Track graphql
Now that we know that we don't want anyone to just update or add tracks without an album that is attached to it we don't need any other endpoints we just need to set some public and mark some relationships. First let's head to lib/tunez/music/album.ex
relationships do
belongs_to :artist, Tunez.Music.Artist do
allow_nil?(false)
end
belongs_to(:created_by, Tunez.Accounts.User)
belongs_to(:updated_by, Tunez.Accounts.User)
has_many :tracks, Tunez.Music.Track do
sort(order: :asc)
public?(true)
end
end
Now we can make the tracks public lib/tunez/music/track.ex
attributes do
uuid_primary_key(:id)
attribute :order, :integer do
allow_nil?(false)
end
attribute :name, :string do
allow_nil?(false)
public?(true)
end
#...
end
# ...
calculations do
calculate(:number, :integer, expr(order + 1), public?: true)
calculate(:duration, :string, Tunez.Music.Calculations.SecondsToMinutes, public?: true)
end
Special Treatment for the JSON API
Just the last thing for the JSON API we need to make sure that we include some tracks in the API. lib/tunez/music/artist.ex
json_api do
type("artist")
includes(albums: [:tracks])
end
lib/tunez/music/track.ex
json_api do
type("track")
default_fields([:number, :name, :duration ])
end
That was a lot to do in a day but we have it all done. Good Luck