Make sure you're familiar with the basics of AshPhoenix.Form
before reading this guide.
When we talk about "nested" or "related" forms, we mean sets of form inputs that are for resource actions for related or embedded resources.
For example, you might have a form for creating a "business" that can also include multiple "locations". In some cases, you may have buttons to add or remove from a list of nested forms, you may be able to drag and drop to reorder forms, etc. In other cases, the form may just be for one related thing, think a form for updating a "user" that also contains inputs for its associated "profile".
AshPhoenix.Form
automatically infers what "nested forms" are available, based on introspecting actions
which use change manage_relationship
. For example, in the following action:
# on a `MyApp.Operations.Business` resource
create :create do
accept [:name]
argument :locations, {:array, :map}
change manage_relationship(:locations, type: :create)
end
With this action, you could submit an input like so:
%{name: "Wally World", locations: [%{name: "HQ", address: "1 hq street"}]}
AshPhoenix.Form
will look at the action, allowing you to use Phoenix
's
<.inputs_for
component for locations
. Here is what it might look like in
practice:
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<.inputs_for :let={location} field={@form[:locations]}>
<.input field={location[:name]} />
</.inputs_for>
</.form>
To turn this automatic behavior off, you can specify forms: [auto?: false]
when creating the form.
You can manually specify nested form configurations using the forms
option.
For example:
AshPhoenix.Form.for_create(
MyApp.Operations.Business,
:create,
forms: [
locations: [
type: :list,
resource: MyApp.Operations.Location,
create_action: :create
]
]
)
You should prefer to use the automatic form definition wherever possible, but this exists as an escape hatch to customize configuration.
You should be sure to load any relationships that are necessary for your
manage_relationship
s when you want to update the nested items.
For example, if the form above was for an update action,
you may want to allow updating the existing locations all in a single form.
AshPhoenix.Form
will show a form for each existing location, but only if
the locations are loaded on the business already. For example:
business = Ash.load!(business, :locations)
form = AshPhoenix.Form.for_update(business, :update)
If you're not using tailwind, you'll need to replace
class="hidden"
in the examples below with something else. In standard HTML, you'd do<input .... hidden />
. As long as the checkbox is hidden, you're good!
There are two ways to add nested forms.
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<.inputs_for :let={location} field={@form[:locations]}>
<.input field={location[:name]} />
</.inputs_for>
<label>
<input
type="checkbox"
name={"#{@form.name}[_add_locations]"}
value="end"
class="hidden"
/>
<.icon name="hero-plus" />
</label>
</.form>
This checkbox, when checked, will add a parameter like form[_add_locations]=end
.
When AshPhoenix.Form
is handling nested forms, it will see that and append an empty
form at the end. Valid values are "start"
, "end"
and an index, i.e "3"
, in which
case the new form will be inserted at that index.
But the checkbox is hidden, what gives? {: .info}
If you're anything like me, the label + checkbox combo above may confuse you at first sight. When you have a checkbox inside of a label, clicking on the label counts as clicking the checkbox itself!
In some cases, you may want to add a form either in a way that can't be triggered by a checkbox
or that requires some additional data (like non-empty starting params). In those cases,
you can use a button and a handle_event
For example:
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<.inputs_for :let={location} field={@form[:locations]}>
<.input field={location[:name]} />
</.inputs_for>
<.button type="button" phx-click="add-form" phx-value-path={@form.name <> "[locations]"}>
<.icon name="hero-plus" />
</.button>
</.form>
By always using a path "relative" to the root form, we can handle cases where we are adding a form to a multiply-nested form. So the path could be somethign like locations[0][addresses][1]. The event handler has to know exactly where we are adding a form. In the example above, we could just say
add_form(form, :locations)
. It would be simpler, but we want to highlight how to work with potentially deeply nested data.
def handle_event("add-form", %{"path" => path}, socket) do
form = AshPhoenix.Form.add_form(socket.assigns.form, path, params: %{
address: "Put your address here!"
})
{:noreply, assign(socket, :form, form)}
end
Just like adding nested forms, there are two ways to remove nested forms.
The _drop_*
checkbox uses checkboxes which add form indices to a list that should
be removed from the list. For example, given the following:
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<.inputs_for :let={location} field={@form[:locations]}>
<.input field={location[:name]} />
<label>
<input
type="checkbox"
name={"#{@form.name}[_drop_locations][]"}
value={location_form.index}
class="hidden"
/>
<.icon name="hero-x-mark" />
</label>
</.inputs_for>
</.form>
When the checkbox is checked, the server sees:
%{"form" => %{"_drop_locations" => ["0"]}}
We use this information to automatically remove the item at that index on validate.
Just like adding forms, there is a manual way to remove forms. In this case we pass the full path to the form being removed.
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<.inputs_for :let={location} field={@form[:locations]}>
<.input field={location[:name]} />
<.button type="button" phx-click="remove-form" phx-value-path={location.name}>
<.icon name="hero-x-mark" />
</.button>
</.inputs_for>
</.form>
def handle_event("remove-form", %{"path" => path}, socket) do
form = AshPhoenix.Form.remove_form(socket.assigns.form, path)
{:noreply, assign(socket, :form, form)}
end
Just like adding and removing forms, there are two ways to sort nested forms too!
This method is useful when combined with something like sortable.js
to allow for dragging and dropping on the front end.
If you are working with a sorted relationship, you will likely want to couple it with the
order_is_key
option ofmanaged_relationships
. This writes the order of items in the list of inputs into each input, as if it was provided as an input
change manage_relationship(:locations, type: :direct_control, order_is_key: :position)
In the above example, if you provided a list of inputs like[%{address: "foo"}, %{address: "bar"}]
, it would first be converted into[%{address: "foo, order: 0}, %{address: "bar", order: 1}]
before being processed.
Lets say you had the following Sortable
hook in your app.js
import Sortable from "sortablejs"
export const Sortable = {
mounted() {
new Sortable(this.el, {
animation: 150,
draggable: '[data-sortable="true"]',
ghostClass: "bg-yellow-100",
dragClass: "shadow-2xl",
onEnd: (evt) => {
this.el.closest("form").querySelector("input").dispatchEvent(new Event("input", {bubbles: true}))
}
})
}
}
...
let Hooks = {}
Hooks.Sortable = Sortable
You could use the _sort_*
checkbox in each nested form like so:
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<div id="location-list" phx-hook="Sortable">
<.inputs_for :let={location} field={@form[:locations]}>
<div data-sortable="true">
<input
type="hidden"
name={"#{@form.name}[_sort_locations][]"}
value={location_form.index}
/>
<.input field={location[:name]} />
</div>
</.inputs_for>
</.form>
In this case you'd drag the entire div. sortable.js
supports all kinds of useful features,
like drag handles. See their docs for more.
Now, lets say you were to drag the second form above the first form, the server would see the params as:
%{"form" => %{"_sort_locations" => ["1", "0"]}}
AshPhoenix.Form
would then sort the nested forms accordingly.
The manual way is using AshPhoenix.Form.sort_forms/3
. This can be used
to move a specific element up or down, or to sort all forms. sortable.js
can be used in such a way that it provides the full sorting back to your
server.
This could be used to send a handle_event
that gives you a list
of indices in a new order. An example of that setup can be seen
here. Keep in mind that you'll want to adjust the method to extract a field from
each element of the current index, using something like data-current-index={location_form.index}
to
store the index.
indices
might look something like this: ["0", "1", "3", "2"]
def handle_event("update-sorting", %{"path" => path, "indices" => indices}, socket) do
form = AshPhoenix.Form.sort_forms(socket, path, indices)
{:noreply, assign(socket, form: form)}
end
If you wanted up/down buttons, you could use event handlers like the following.
def handle_event("move-up", %{"path" => form_to_move}, socket) do
# decrement typically means "move up" visually
# because forms are rendered down the page ascending
form = AshPhoenix.Form.sort_forms(socket, form_to_move, :decrement)
{:noreply, assign(socket, form: form)}
end
def handle_event("move-down", %{"path" => form_to_move}, socket) do
# increment typically means "move down" visually
# because forms are rendered down the page ascending
form = AshPhoenix.Form.sort_forms(socket, form_to_move, :increment)
{:noreply, assign(socket, form: form)}
end
Lets look at what it looks like with all of the checkbox-based features in one:
defmodule MyApp.MyForm do
use MyAppWeb, :live_view
def render(assigns) do
~H"""
<.simple_form for={@form} phx-change="validate" phx-submit="submit">
<.input field={@form[:email]} />
<!-- Use sortable.js to allow sorting nested input -->
<div id="location-list" phx-hook="Sortable">
<.inputs_for :let={location} field={@form[:locations]}>
<!-- inputs each nested location -->
<div data-sortable="true">
<!-- AshPhoenix.Form automatically applies this sort -->
<input
type="hidden"
name={"#{@form.name}[_sort_locations][]"}
value={location_form.index}
/>
<.input field={location[:name]} />
<!-- AshPhoenix.Form automatically removes items when checked -->
<label>
<input
type="checkbox"
name={"#{@form.name}[_drop_locations][]"}
value={location_form.index}
class="hidden"
/>
<.icon name="hero-x-mark" />
</label>
</div>
</.inputs_for>
<!-- AshPhoenix.Form automatically appends a new item when checked -->
<label>
<input
type="checkbox"
name={"#{@form.name}[_add_locations]"}
value="end"
class="hidden"
/>
<.icon name="hero-plus" />
</label>
</div>
</.form>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: MyApp.Operations.form_to_create_business())}
end
def handle_event(socket, "validate", %{"form" => params}) do
{:noreply, assign(socket, :form, AshPhoenix.Form.validate(socket.assigns.form, params))}
end
def handle_event(socket, "submit", %{"form" => params}) do
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
{:ok, business} ->
socket =
socket
|> put_flash(:success, "Business created successfully")
|> push_navigate(to: ~p"/businesses/#{business.id}")
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, :form, form)}
end
end
end