Skip to content

Latest commit

 

History

History
443 lines (341 loc) · 13.4 KB

nested-forms.md

File metadata and controls

443 lines (341 loc) · 13.4 KB

Nested Forms

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".

Defining the structure

Inferring from the action

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.

Manually defining nested forms

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.

Updating existing data

You should be sure to load any relationships that are necessary for your manage_relationships 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)

Not using tailwind? {: .warning}

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!

Adding nested forms

There are two ways to add nested forms.

The _add_* checkbox

<.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!

AshPhoenix.Form.add_form

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>

whats with @form.name <> "[locations]" {: .info}

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

Removing nested forms

Just like adding nested forms, there are two ways to remove nested forms.

Using the _drop_* checkbox

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.

Using AshPhoenix.Form.remove_form

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

Sorting nested forms

Just like adding and removing forms, there are two ways to sort nested forms too!

Using _sort_* checkboxes

This method is useful when combined with something like sortable.js to allow for dragging and dropping on the front end.

the order_is_key option {: .info}

If you are working with a sorted relationship, you will likely want to couple it with the order_is_key option of managed_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.

Using AshPhoenix.Form.sort_forms/3

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.

Providing a full sort order

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

Moving a specific form up

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

Putting it all together

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