How to create AshPhoenix form with Union field

I’m trying to create a form which has a list of nested items, each of which contain some union field. I have the following hierarchy: WorkoutTemplate has many SetGroupTemplate where SetGroupTemplate looks like the following module and both FixedReps and ClassicOverload are embedded ash resources

defmodule Weightroom.Template.SetGroupTemplate do
  @moduledoc false

  use Ash.Resource,
    domain: Weightroom.Template,
    data_layer: AshPostgres.DataLayer

  alias Weightroom.Template.Progression

  attributes do
    uuid_primary_key :id
    attribute :order, :integer, public?: true, allow_nil?: false

    attribute :progression, :union do
      public? true
      allow_nil? false

      constraints types: [
                    fixed_reps: [
                      type: Progression.FixedReps,
                      tag: :type,
                      tag_value: :fixed_reps
                    ],
                    classic_overload: [
                      type: Progression.ClassicOverload,
                      tag: :type,
                      tag_value: :classic_overload
                    ]
                  ]
    end
  end

  relationships do
    belongs_to :workout, Weightroom.Template.WorkoutTemplate do
      allow_nil? false
    end

    belongs_to :exercise, Weightroom.Journal.Exercise do
      allow_nil? false
      public? true
    end
  end

  postgres do
    table "set_group_templates"
    repo Weightroom.Repo

    references do
      reference :workout, on_delete: :delete
      reference :exercise, on_delete: :delete
    end

    custom_statements do
      statement :unique_order do
        up ~s|ALTER TABLE set_group_templates ADD CONSTRAINT set_group_templates_unique_order_index UNIQUE (workout_id, "order") DEFERRABLE INITIALLY DEFERRED|

        down "ALTER TABLE set_group_templates DROP CONSTRAINT set_group_templates_unique_order_index"
      end
    end
  end

  actions do
    defaults [:read, :destroy, create: :*]
  end

When I create the form and try to add a new SetGroupTemplate I get the following error

    template
    |> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
    |> AshPhoenix.Form.add_form("form[set_groups]",
      params: %{
        "progression" => %{
          "_union_type" => "fixed_reps"
        }
      }
    )

** (RuntimeError) Got no "_union_type" parameter, and no union type had a tag & tag_value pair matching the params.

If you are adding a form, select a type using `params: %{"_union_type" => "type_name"}`, or if one
or more of your types is using a tag you can set that tag with `params: %{"tag" => "tag_value"}`.

Params:

%{}

Available types:

[
  classic_overload: [
    type: Weightroom.Template.Progression.ClassicOverload,
    constraints: [on_update: :update_on_match],
    tag: :type,
    tag_value: :classic_overload
  ],
  fixed_reps: [
    type: Weightroom.Template.Progression.FixedReps,
    constraints: [on_update: :update_on_match],
    tag: :type,
    tag_value: :fixed_reps
  ]
]

If I instead use “type” instead of “_union_type” as the key for the new progression I get the same error. Stepping through the code of ash authentication it looks like AshPhoenix.Form.Auto.determine_type/3 is called twice. The first time with the map of params and it doesn’t raise. The second time, params is empty and the function raises.

I saw there is a test for union forms here and I can’t see what I’m doing differently other than using an embedded resource for the union type instead of a simple type like string/int/etc. Any help understanding where I’m going wrong would be appreciated.

Hey @brady131313 we’re actually actively working on some issues around union types. Can you try main of ash_phoenix and tell me how it goes?

@zachdaniel using main of ash_phoenix works as expected. I can pass the type of the union using both :_union_type and :tag and it works, thank you.

1 Like

I’m trying to follow the docs:

defmodule PageBlock do
  use Ash.Type.NewType,
    subtype_of: :union,
    constraints: [
      types: [
        hero_simple: [
          type: HeroSimple,
          tag: :type,
          tag_value: :hero_simple
        ],
        hero_media: [
          type: HeroMedia,
          tag: :type,
          tag_value: :hero_media
        ]
      ]
    ]
end

But trying to use this in a resource like so:

attribute :blocks, {:array, PageBlock}, public?: true

Throws error:

** (RuntimeError) {:array, PageBlock} is not a valid type.

Valid types include any custom types, or the following short codes (alongside the types they map to):

Didn’t want to create a new post as i think it’s part of this. I’m using main branch of ash_phoenix

      {:ash, "~> 3.0"},
      {:ash_postgres, "~> 2.0"},
      {:ash_phoenix, github: "ash-project/ash_phoenix", branch: "main"},
      {:picosat_elixir, "~> 0.2.3"},

I’ll try doing it like brady131313

Ok figured it out,
the

defmodule Octafest.Pages.Block do
  use Ash.Type.NewType,
    subtype_of: :union,
    constraints: [
      types: [
        hero_simple: [
          type: HeroSimple,
          tag: :type,
          tag_value: :hero_simple
        ],
        hero_media: [
          type: HeroMedia,
          tag: :type,
          tag_value: :hero_media
        ]
      ]
    ]
end

has to be in a single file by itself
Same for all the others subtypes

Ok running into an issue:

[debug] HANDLE EVENT "save" in OctafestWeb.PageLive.Show
  Parameters: %{"form" => %{"blocks" => %{"0" => %{"_form_type" => "create", "_persistent_id" => "0", "_touched" => "_form_type,_persistent_id,_touched,_union_type,description,navigation_type,title,type", "_union_type" => "hero_media", "description" => "Some description", "navigation_type" => "simple", "title" => "Some title", "type" => "hero_media"}}}}
[warning] Unhandled error in form submission for Octafest.Pages.Page.update_blocks

This error was unhandled because Ash.Error.Framework.MustBeAtomic does not implement the `AshPhoenix.FormData.Error` protocol.

** (Ash.Error.Framework.MustBeAtomic) Octafest.Pages.Page.update_blocks must be performed atomically, but it could not be

Reason: Unions do not support atomic updates

See https://hexdocs.pm/ash/3.0.0/update-actions.html#atomic-updates for more on atomics.

This is my actions on the page resource:

  actions do
    # Truncated...
    update :update_blocks do
      # accept content as input
      accept [:blocks]
    end

Also is there a way to automatically add a nested ember form:

defmodule Octafest.Pages.Block.HeroMedia do
  use Ash.Resource, data_layer: :embedded

  attributes do
    uuid_primary_key :id
    attribute :title, :string, allow_nil?: false
    attribute :description, :string
    attribute :navigation_type, :string
    attribute :image, Octafest.Pages.Components.Image
    attribute :button, Octafest.Pages.Components.Button
  end

  actions do
    defaults [
      :read,
      :destroy,
      create: [:title, :navigation_type, :description, :image, :button],
      update: [:title, :navigation_type, :description, :image, :button]
    ]
  end
end

Here is my image resource:

defmodule Octafest.Pages.Components.Image do
  use Ash.Resource, data_layer: :embedded

  attributes do
    uuid_primary_key :id
    attribute :url, :string
    attribute :alt, :string
    attribute :image_id, :string, allow_nil?: false
    attribute :modifiers, :map, default: %{}
  end

  actions do
    defaults [
      :read,
      :destroy,
      create: [
        :url,
        :alt,
        :image_id,
        :modifiers
      ],
      update: [
        :url,
        :alt,
        :image_id,
        :modifiers
      ]
    ]
  end
end

HeaderMedia is part of a union type:

defmodule Octafest.Pages.Block do
  use Ash.Type.NewType,
    subtype_of: :union,
    constraints: [
      types: [
        hero_simple: [
          type: Octafest.Pages.Block.HeroSimple,
          tag: :type,
          tag_value: :hero_simple
        ],
        hero_media: [
          type: Octafest.Pages.Block.HeroMedia,
          tag: :type,
          tag_value: :hero_media
        ]
      ]
    ]
end

the union type is used like so in the page resource:

attribute :blocks, {:array, Octafest.Pages.Block}, public?: true

Here’s how i add the block:

  @impl true
  def handle_event("add_block", %{"type" => type}, socket) do
    form =
      socket.assigns.form
      |> AshPhoenix.Form.add_form("form[blocks]",
        params: %{
          "_union_type" => type
        }
      )

    {:noreply, assign(socket, :form, form)}
  end

How would i add the nested embed image form based on type?
something like:

case type do
   :header_media -> # TODO attach the image form from the start.

You should be able to include nested paths when you add a form, i.e "form[foo][bar][baz]", is that what you’re looking for?

Kind of:
The blocks is an array
so when adding a new block how do i know to add a nested array to that specific index?

  def handle_event("add_block", %{"type" => type}, socket) do
    form =
      socket.assigns.form
      |> AshPhoenix.Form.add_form("form[blocks]",
        params: %{
          "_union_type" => type
        }
      )

    form =
      case type do
        "hero_media" ->
          form
          |> AshPhoenix.Form.add_form("form[blocks][SOME_INDEX][image]",
            params: %{
              "alt" => ""
            }
          )

        _ ->
          form
      end

How would i get the SOME_INDEX value?

Ah, I mean, I’d have to understand more about what you are specifically trying to do. Do you want it to be the case that any form that is an image automatically has an alt form? Or do you want the form you just added to have that nested form?

If you need the former, then you’ll have to look at the form.forms object yourself and traverse it to figure out what forms are there and do some custom processing.

For the latter, perhaps something like this?

  def handle_event("add_block", %{"type" => type}, socket) do
    form =
      socket.assigns.form
      |> AshPhoenix.Form.add_form("form[blocks]",
        params: %{
          "_union_type" => type,
          "image" => %{
             "alt" => ""
          }
        }
      )

Works beautifully:

  def handle_event("add_block", %{"type" => type}, socket) do
    form =
      socket.assigns.form
      |> AshPhoenix.Form.add_form("form[blocks]", params: get_params(type))

    {:noreply, assign(socket, :form, form)}
  end

  defp get_params("hero_media" = type), do: %{
      "_union_type" => type,
      "image" => %{
        "url" => "",
        "alt" => "",
        "image_id" => "",
        "modifiers" => %{}
      }
    }

  defp get_params("hero_simple" = type), do: %{ "_union_type" => type }
  defp get_params(type), do: %{ "_union_type" => type }
1 Like

Now the last issue i’m having is on trying to update the page resource i get a nested error:

form.source.source.errors #=> [
  %Ash.Error.Invalid.NoSuchInput{
    calculation: nil,
    resource: Octafest.Pages.Block.HeroMedia,
    action: :update,
    input: "type",
    inputs: MapSet.new([:description, :title, :image, :button, :navigation_type,
     "button", "description", "image", "navigation_type", "title"]),
    did_you_mean: [],
    splode: Ash.Error,
    bread_crumbs: [],
    vars: [],
    path: [:blocks, 0],
    stacktrace: #Splode.Stacktrace<>,
    class: :invalid
  }
]

Although my inputs_for doesn’t have any “type” input

  def block(%{type: :hero_media} = assigns) do
    ~H"""
    <div class="space-y-4">
      <p class="">
        Hero media
      </p>

      <.image_form field={@form[:image]} />
      <.input field={@form[:title]} type="text" label={dgettext("forms", "title")} />
      <.input field={@form[:description]} type="text" label={dgettext("forms", "description")} />
      <.input
        field={@form[:navigation_type]}
        type="text"
        label={dgettext("forms", "navigation_type")}
      />
      <.button_form field={@form[:button]} />
    </div>
    """
  end

Should i be using something union specific? couldn’t find anything in the docs
Basically what i’m doing is creating a page_builder and if all is good the union type looks like should handle all my needs:

Are you using a union type where there is a tag attribute? If so you, may need something like:

tag: "type",
cast_tag?: false

IIRC that is what the option is called.

Ok figured it out, thanks to Zach!

Correct me if i’m wrong Zach.

If you don’t want to have the type of the module inside the embedded field,
then do this in the union type:

But i would suggest to add the type into the embedded union resource,
if you agree then ignore the above and add the type attribute to the resource like so:

1 Like

Thanks Zach got most of the things working, UNIONS ARE AMAZING:

Now will try to swap two blocks :smiley:

3 Likes

Anyone who worked on swapping unions in an array? @zachdaniel maybe you could point me in the right direction,
The only other thing i think i can do is, remove both subforms and create them from scratch.
Or add an order field and somehow render them sorted by the order?

I’m having great difficulty trying to figure this out.

Basically i have a move and down buttons in the page builder which should swap two blocks, here’s where i’ve got to with code but not sure what to do in the last part:

  def handle_event(
        "move_block",
        %{"id" => id, "direction" => direction, "path" => path_a},
        socket
      ) do
    form = socket.assigns.form

    direction_offset =
      case direction do
        "up" -> -1
        "down" -> 1
        _ -> 0
      end

    # todo take the last part of the id and move it up if it is not the first
    index =
      String.split(id, "_")
      |> List.last()
      |> String.to_integer()

    dbg(index)
    blocks = form[:blocks].value
    block_count = length(blocks)

    new_index =
      (index + direction_offset)
      |> max(0)
      |> min(block_count - 1)

    dbg(new_index)

    if(index == new_index) do
      {:noreply, socket}
    else
      path_b = "form[blocks][#{new_index}]"
      dbg(path_a)
      dbg(path_b)
      block_a = AshPhoenix.Form.get_form(form, path_a)
      block_b = AshPhoenix.Form.get_form(form, path_b)

      dbg(block_a)
      dbg(block_b)

      # form =
      #   form
      #   |> AshPhoenix.Form.update_form(path_a, fn -> block_b end, mark_as_touched?: false)
      #   |> AshPhoenix.Form.update_form(path_b, fn -> block_a end, mark_as_touched?: false)

      # {:noreply, assign(socket, :form, form)}
      {:noreply, socket}
    end
  end

At the moment, direct manipulation of the child forms is the best way to do it, but I’m going to be revisiting this this week/next week, specifically aiming to solve the problem of the complexities around sortable nested forms: Ash Framework Roadmap · GitHub

1 Like

Could you be any more awesome!!!
That even has a solution for now as well!!!
Thank you!!!