Forms For Relationships Between Existing Records
View SourceMake 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
endThen 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
endServiceLocation 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
endDeclaring 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)
endNote: 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", [])
endWhen 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.