Home Posts Post Search Tag Search

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 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