Forms For Relationships Between Existing Records

View Source

Make sure you're familiar with the basics of AshPhoenix.Form and relationships before reading this guide.

When we talk about "relationships between existing records", we mean inputs on a form that manage the relationships between records that already exist.

For example, you might have a form for creating a "service" that can be performed at some "locations", but not others. When creating or updating a service, the user is only able to select from the existing locations.

Defining the resources and relationships

First, we have a simple Location

defmodule MyApp.Operations.Location do
  use Ash.Resource,
    otp_app: :my_app,
    domain: MyApp.Operations,
    data_layer: AshPostgres.DataLayer

  ...

  attributes do
    integer_primary_key :id

    attribute :name, :string do
      allow_nil? false
    end
  end
end

Then we have a Service, which has a many_to_many association to Location, through ServiceLocation. We add a list aggregate for :location_ids for populating the form values.

defmodule MyApp.Operations.Service do
  use Ash.Resource,
    otp_app: :my_app,
    domain: MyApp.Operations,
    data_layer: AshPostgres.DataLayer

  ...

  relationships do
    has_many :location_relationships, MyApp.Operations.ServiceLocation do
      destination_attribute :service_id
    end

    many_to_many :locations, MyApp.Operations.Location do
      join_relationship :location_relationships
      source_attribute_on_join_resource :service_id
      destination_attribute_on_join_resource :location_id
    end
  end

  aggregates do
    list :location_ids, :locations, :id
  end
end

ServiceLocation has default actions as well as the relationships declared to operate as the joining resource between a Service and one or more Locations.

defmodule MyApp.Operations.ServiceLocation do
  use Ash.Resource,
    otp_app: :my_app,
    domain: MyApp.Operations,
    data_layer: AshPostgres.DataLayer

  ...

  actions do
    defaults [:create, :read, :update, :destroy]
    default_accept [:service_id, :location_id]
  end

  relationships do
    belongs_to :service, MyApp.Operations.Service do
      attribute_type :integer
      allow_nil? false
      primary_key? true
    end

    belongs_to :location, MyApp.Operations.Location do
      attribute_type :integer
      allow_nil? false
      primary_key? true
    end
  end
end

Declaring the create and update actions

First, we need to update our Service and declare custom create and update actions, which take a list of Location ids as an argument. We use type: :append_and_remove to cause a ServiceLocation to be added or removed for each Location as we add and remove them using our form. (See Ash.Changeset.manage_relationship/4 for more.)

# in lib/my_app/operations/service.ex
create :create do
  accept [:name]
  primary? true
  argument :location_ids, {:array, :integer}, allow_nil?: true

  change manage_relationship(:location_ids, :locations, type: :append_and_remove)
end

update :update do
  accept [:name]
  primary? true
  argument :location_ids, {:array, :integer}, allow_nil?: true
  require_atomic? false

  change manage_relationship(:location_ids, :locations, type: :append_and_remove)
end

Note: in this example, we are using integer_primary_key, so the argument's type is {:array, :integer}. If we were using uuid_primary_key, the type would be {:array, :uuid}.

Now we can create and update our Services.

iex> service = Ash.create!(Service, %{name: "Tuneup", location_ids: [location_1_id, location_2_id]}, load: [:locations])
 %MyApp.Operations.Service{
  id: 9,
  name: "Tuneup",
  location_relationships: [
    %MyApp.Operations.ServiceLocation{ service_id: 9, location_id: 1, ... },
    %MyApp.Operations.ServiceLocation{ service_id: 9, location_id: 2, ... }
  ],
  locations: [
    %MyApp.Operations.Location{ id: 1, name: "HQ", ... },
    %MyApp.Operations.Location{ id: 2, name: "Downtown", ... }
  ],
  ...
}
iex> Ash.update!(service, %{location_ids: [location_2_id]}, load: [:locations])
%MyApp.Operations.Service{
  id: 9,
  name: "Tuneup",
  location_relationships: [
    %MyApp.Operations.ServiceLocation{ service_id: 9, location_id: 2, ... }
  ],
  locations: [
    %MyApp.Operations.Location{ id: 2, name: "Downtown", ... }
  ],
  ...
}

Now, let's expose this to a user.

Adding the forms

In our view, we create our form as normal. For update forms, we'll make sure to load our locations.

We use the :prepare_params option with our for_update form to set "location_ids" to an empty list if no value is provided. This allows the user to de-select all Locations to update a Service so that it's not available at any Location.

# lib/my_app_web/service_live/form_component.ex
defp assign_form(%{assigns: %{service: service}} = socket) do
  form =
    if service do
      service
      |> Ash.load!([:locations, :location_ids])
      |> AshPhoenix.Form.for_update(:update, as: "service", prepare_params: &prepare_params/2)
    else
      AshPhoenix.Form.for_create(MyApp.Operations.Service, :create, as: "service")
    end

  assign(socket, form: to_form(form))
end

defp prepare_params(params, :validate) do
  Map.put_new(params, "location_ids", [])
end

When rendering the form, we'll have to manually provide the options to our input. Using Phoenix generated core components, options is passed to Phoenix.HTML.Form.options_for_select/2, which expects a list of two-element tuples.

Assuming the available Locations are already assigned to @locations:

<.input
  field={@form[:location_ids]}
  type="select"
  multiple
  label="Locations"
  options={Enum.map(@locations, &{&1.name, &1.id})}
/>

Now, when our form is submitted, we will receive a list of location ids.

%{"service" => %{"locations" => ["1", "2"], "name" => "Overhaul"}}

That's all we need to do. We can pass these parameters to AshPhoenix.Form.submit/2 as normal and manage_relationship will create and destroy our ServiceLocation records as needed.