How to reorder items in a list in Ash framework

Hi everyone,

I have a list of items with a position attribute that needs to be updated whenever an item’s position changes (e.g., after a drag-and-drop event in the UI). I found an example of how this can be done in Ecto here, but I’m wondering if there’s an “Ash way” to achieve this.

Has anyone implemented something similar in Ash? Any guidance would be appreciated!

Thanks!

You could use an action like this:

# sets the position and reorders
update :move_position do
  accept [:position]

  change YourResource.Changes.RepositionAll
end

update :decrement_position do
  change atomic_update(:position, position - 1)
end

update :increment_position do
  change atomic_update(:position, position - 1)
end

Some adaptation of the code you linked:

defmodule YourResource.Changes.RepositionAll do
  use Ash.Resource.Change
  require Ash.Query
  import Ash.Expr

  def change(changeset, opts, _) do
    changeset
    |> Ash.Changeset.before_action(changeset, fn changeset -> 
      requested_position = Ash.Changeset.get_attribute(changeset, :position)

      max_position =
        resource
        # put in whatever grouping you're sorting these within
        |> Ash.Query.filter(group_id == ^Ash.Changeset.get_attribute(changeset, :group_id))
        |> Ash.count!()

     new_position = min(requested_position, max_position)

      id = Ash.Changeset.get_attribute(changeset, :id)
     
      with %Ash.BulkResult{status: :succes} <- shift_down(id, new_position),
              %Ash.BulkResult{status: :succes} <- shift_up(id, new_position) do
         changeset
      else
         %Ash.BulkResult{errors: errors} -> Ash.Changeset.add_error(changeset, errors)
      end
    end)
  end

  def shift_down(resource, id, new_position) do
    resource
    |> Ash.Query.filter(position > ^old_position(id) and position <= new_position)
    |> Ash.Query.bulk_update!(:decrement_position)
  end

  def shift_up(id, new_position) do
    resource
    |> Ash.Query.filter(position < ^old_position(id) and position > new_position)
    |> Ash.Query.bulk_update!(:decrement_position)
  end

  def old_position(id) do
    expr(fragment("SELECT position FROM table WHERE id = ?", ^id))
  end
end

One small note:

old_position = from(og in type, where: og.id == ^struct.id, select: og.position)

stating the above is currently a limitation in Ash expressions, that will be addressed soon: Support resources as aggregate targets in expressions · Issue #939 · ash-project/ash · GitHub

So for that reason, I’ve done it as a SQL fragment, which is not ideal.

I didn’t run any of the above code, but it should be a reasonable place to get started :slight_smile:

5 Likes

Works! Thank you

When would you use order_is_key to reorder items?

  actions do
    defaults [:read, :destroy]

    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
  end

Is there a way to do this bulk update with Ash?

UPDATE tree_nodes
SET sort_order = data.new_sort_order
FROM (VALUES
   (42, 1),
   (37, 2),
   (19, 3),
   (84, 4)
) AS data(child_id, new_sort_order)
WHERE tree_nodes.id = data.child_id
 AND tree_nodes.parent_id = 10;

Only kind of. It’s not exactly the same. (keep in mind if you need to you can just use Ecto with your resources :smiley:

update :reorder do
  argument :new_positions, :map, allow_nil?: false

  change fn changeset, _ ->
    ids = Map.keys(changeset.arguments[:new_positions] || %{})
    Ash.Changeset.filter(changeset, expr(id in ^ids))
  end

  change fn changeset, _ ->
    expr = 
      Enum.reduce(new_positions, expr(child_id), fn {id, position}, expr -> 
        expr(
          if id == ^id do
            ^position
          else
            ^expr
          end
        )
    end)

    Ash.Changeset.atomic_update(changeset, :sort_order, expr)
  end
end

And then you could:

Ash.bulk_update!(Things, :reorder, %{42 => 1, 37 => 2, 19 => 3, 84 => 4})

I haven’t run the code above, just wrote it up here as an example. It’s not the values list approach that you have, it becomes a case statement. I’d like to support that style of bulk updates in the future, but we don’t currently :smiley:

1 Like