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