ash-project / ash_phoenix

Utilities for integrating Ash and Phoenix
https://hexdocs.pm/ash_phoenix
MIT License
106 stars 60 forks source link

A helper function to aid in re-ordering records in nested forms #142

Open sevenseacat opened 8 months ago

sevenseacat commented 8 months ago

Is your feature request related to a problem? Please describe.

When integrating a library like SortableJS for drag and drop reordering of records in nested forms, it's currently super-cumbersome to properly reorder them within an AshPhoenix form so they don't re-render in their old order.

Describe the solution you'd like

Our current setup has SortableJS set up to send an LV event with the item paths in the desired order, eg. def handle_event("reposition", %{"items" => ["form[steps][1]", "form[steps][0]"]}, socket). Each resource (in this case Step) has a position field to determine the order it should appear in.

We can get the new position value for each record with Enum.with_index, so it would be awesome to be able to pass that in to a new helper function within AshPhoenix to do the setting of the values and re-ordering in one fell swoop.

It could be something like

def handle_event("reposition", %{"items" => paths}, socket) do 
  order_field = :position
  new_positions = Enum.with_index(paths)

  form = 
    socket.assigns.form
    |> AshPhoenix.reorder_nested(new_positions, order_field) # new helper?

  # ... rest

Describe alternatives you've considered

Currently we've (well, with your help) got it done inline manually, with some gnarly code that looks like the following:

    updates =
      paths
      |> Enum.with_index()
      |> Enum.map(fn {path, index} -> %{form_path: path, position: index} end)

    socket =
      Enum.reduce(updates, socket, fn %{form_path: path, position: position}, socket ->
        update_nested_form_value(socket, path, "position", position)
      end)
      |> update(:form, fn form ->
        Map.update!(form, :forms, fn forms ->
          Map.update!(forms, :steps, fn nested ->
            Enum.sort_by(nested, fn nested_form ->
              Ash.Changeset.get_attribute(nested_form.source, :position)
            end)
            |> Enum.map(fn nested_form ->
              position = AshPhoenix.Form.value(nested_form, :position)

              %{
                nested_form
                | name: form.name <> "[#{key}][#{position}]",
                  id: form.id <> "_#{key}_#{position}"
              }
            end)
          end)
        end)
      end)

where update_nested_form_value is defined as:

  defp update_nested_form_value(socket, form_path, field_name, value) do
    updated_form =
      AshPhoenix.Form.update_form(socket.assigns.form, form_path, fn form ->
        updated_params = Map.put(AshPhoenix.Form.params(form), field_name, value)
        AshPhoenix.Form.validate(form, updated_params)
      end)

    assign(socket, form: updated_form)
  end

Which works but it's not pretty by any means. Plus it would need to be copied and pasted and rewritten if we had a more deeply nested form.

It seems like a reasonable candidate for having a helper for it in AshPhoenix IMO!

sevenseacat commented 4 months ago

With Ecto you'd do this with sort_param on cast_assoc, eg.

# Schema
cast_embed(changeset, :addresses, sort_param: :addresses_sort)

# Data
%{"name" => "john doe", "addresses" => %{  
  0 => %{"street" => "somewhere", "country" => "brazil", "id" => 1},
  1 => %{"street" => "elsewhere", "country" => "poland"}
}, "addresses_sort" => [1, 0]}

This would re-order the records in memory so when the form re-renders, things render in the correct order.

I think you'd typically want to persist the order though, with an attribute on the resource? My initial example used a position attribute.