We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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 an 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 in 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 in 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 in 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 in 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 an 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.” Let’s 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 in 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
That was it; we can now create a new album with tracks and add them as we go or in a batch with the right map.
iex(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
)
end
Adding and Removing Tracks via the Form
AshPhoenix.form has functionality for this in the form of add_form and remove_form.
Adding New Rows for Track Data
def handle_event("add-track", _params, socket) do
socket =
update(socket, :form, fn form ->
order = length(AshPhoenix.Form.value(form[:tracks]) || []) + 1
AshPhoenix.Form.add_form(form, :tracks, params: %{"order" => order})
end)
{:noreply, socket}
end
Removing Existing Rows of Track Data
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 Policies?
We can restrict access using policies in 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]
policies do
policy always() do
authorize_if(accessing_from(Tunez.Music.Album, :tracks))
authorize_if(action_type(:read))
end
end
end
Reorder All of the Tracks!!!
Automatic Track Numbering in 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
Remove the order field from the form:
<tr data-id={track_form.index}>
<td class="px-3 w-20">
<%!-- <.input field={track_form[:order]} type="number" /> --%>
</td>
</tr>
Ordering Numbering Calculation in lib/tunez/music/track.ex:
calculations do
calculate :number, :integer, expr(order + 1)
end
preparations do
prepare build(load: [:number])
end
Track Duration Conversion in 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
Track Duration Conversion Change in 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
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
JSON API and GraphQL Extensions:
mix ash.extend Tunez.Music.Track json_api
mix ash.extend Tunez.Music.Track graphql
Make tracks public in lib/tunez/music/album.ex:
relationships do
has_many :tracks, Tunez.Music.Track do
sort(order: :asc)
public?(true)
end
end
attributes do
attribute :name, :string do
public?(true)
end
end
calculations do
calculate(:number, :integer, expr(order + 1), public?: true)
calculate(:duration, :string, Tunez.Music.Calculations.SecondsToMinutes, public?: true)
end
JSON API response for Artist and Track:
json_api do
type("artist")
includes(albums: [:tracks])
end
json_api do
type("track")
default_fields([:number, :name, :duration])
end