Repeating Child Forms with InlineCRUD

View Source

Backpex.Fields.InlineCRUD renders repeated child forms. AshBackpex adds move-up and move-down controls alongside Backpex's add and delete controls, and supports the field as an opt-in for has_many relationships. The default Backpex.Fields.HasMany selection UI remains unchanged.

The demo uses InlineCRUD to edit an article's comments directly in the article form. Each comment has a body, author, sentiment, and approval status. The complete working modules are Demo.Blog.Post, Demo.Blog.Comment, and DemoWeb.PostLive.

Define the Relationship

The parent resource exposes a has_many relationship:

defmodule Demo.Blog.Post do
  use Ash.Resource,
    domain: Demo.Blog,
    data_layer: AshSqlite.DataLayer

  relationships do
    has_many :comments, Demo.Blog.Comment
  end
end

The child resource needs create, update, and destroy actions appropriate for the fields submitted by the repeated form. In the demo, body, sentiment, approved, and author_id are editable:

defmodule Demo.Blog.Comment do
  use Ash.Resource,
    domain: Demo.Blog,
    data_layer: AshSqlite.DataLayer

  attributes do
    uuid_primary_key :id

    attribute :body, :string do
      allow_nil? false
      public? true
    end

    attribute :sentiment, :atom do
      allow_nil? false
      default :neutral
      public? true
      constraints one_of: [:positive, :neutral, :critical]
    end

    attribute :approved, :boolean do
      allow_nil? false
      default false
      public? true
    end
  end

  relationships do
    belongs_to :post, Demo.Blog.Post do
      allow_nil? false
    end

    belongs_to :author, Demo.Blog.Author do
      allow_nil? false
    end
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      primary? true
      accept [:body, :sentiment, :approved, :post_id, :author_id]
    end

    update :update do
      primary? true
      accept [:body, :sentiment, :approved, :post_id, :author_id]
    end
  end
end

Manage Comments in the Parent Actions

The parent create and update actions need an array-of-maps argument connected to the relationship with manage_relationship. The demo uses dedicated admin actions because its standard Post actions do not edit comments:

defmodule Demo.Blog.Post do
  use Ash.Resource,
    domain: Demo.Blog,
    data_layer: AshSqlite.DataLayer

  actions do
    create :admin_create do
      # Post attributes and other relationship arguments are omitted here.
      argument :comments, {:array, :map}, allow_nil?: true
      change manage_relationship(:comments, type: :direct_control)
    end

    update :admin_update do
      # Post attributes and other relationship arguments are omitted here.
      require_atomic? false
      argument :comments, {:array, :map}, allow_nil?: true
      change manage_relationship(:comments, type: :direct_control)
    end
  end
end

:direct_control creates new comments, updates comments whose primary keys are present, and destroys existing comments omitted from the submitted list.

Configure the LiveResource

Point the LiveResource at the actions above, load the relationship, then opt the relationship field into InlineCRUD:

defmodule DemoWeb.PostLive do
  use AshBackpex.LiveResource

  backpex do
    resource Demo.Blog.Post
    create_action :admin_create
    update_action :admin_update
    load [:author, :comments, :topic_tags, :audience_tags, :word_count, :comment_count]

    panels(
      content: "Content",
      publishing: "Publishing",
      relationships: "Relationships"
    )

    fields do
      field :comments do
        module Backpex.Fields.InlineCRUD
        except [:index]
        live_resource DemoWeb.CommentLive
        panel :relationships

        child_fields do
          field :body, Backpex.Fields.Textarea do
            label "Body"
            rows 3
          end

          field :author do
            label "Author"
            display_field :name
            typeahead true
            typeahead_limit 10
            prompt "Choose an author"
            class "w-56"
          end

          field :sentiment do
            label "Sentiment"
            options Positive: :positive, Neutral: :neutral, Critical: :critical
            class "w-40"
          end

          field :approved do
            label "Approved"
            class "w-28"
          end
        end
      end
    end
  end
end

The child DSL accepts the same field options as top-level fields. AshBackpex derives child modules and relationship queries from the child resource, so the author typeahead above resolves Demo.Blog.Comment.author, not a relationship on the parent post. Explicit modules still override derivation, as shown by the textarea. live_resource DemoWeb.CommentLive also lets Backpex link comments to their show pages in the read-only view. InlineCRUD uses its shared responsive row layout, so no demo-specific CSS is required to give the body its own row.

For an Ash has_many relationship, AshBackpex supplies InlineCRUD's required type: :assoc option automatically. You can also set type :assoc explicitly.

Submitted Parameters

Backpex submits the repeated forms as an indexed map. It also provides:

  • comments_order[], which contains the submitted child indexes in UI order.
  • comments_delete[], which contains the indexes marked for deletion.
  • comments_move_up[] or comments_move_down[], which contains the index selected by a move control.
  • A hidden id inside each persisted comment form.
  • A hidden _persistent_id that keeps each LiveView form row stable while it moves.

AshBackpex applies a requested move, removes deleted entries, and converts the remaining ordered entries into the list of maps expected by the Ash action. It retains each hidden primary key so manage_relationship can distinguish an update from a create.

The order controls preserve form order during submission. Persisting that order after reloading is still part of the child resource model; add a position attribute and set it from your child actions if the relationship needs durable ordering.

Recursive Embedded Configuration

The demo's article content composition is a compact, real recursive tree: repeated sections contain repeated columns, and each column has one singular target. Unlike relationship InlineCRUD, it needs no custom Backpex field or manage_relationship change. Define each nested node as an Ash embedded resource and accept the root attribute in the parent actions:

defmodule Demo.Blog.ContentTarget do
  use Ash.Resource, data_layer: :embedded

  attributes do
    attribute :kind, :atom, public?: true, constraints: [one_of: [:internal, :external]]
    attribute :path, :string, public?: true
  end
end

defmodule Demo.Blog.ContentColumn do
  use Ash.Resource, data_layer: :embedded

  attributes do
    attribute :heading, :string, public?: true
    attribute :target, Demo.Blog.ContentTarget, public?: true
  end
end

defmodule Demo.Blog.ContentSection do
  use Ash.Resource, data_layer: :embedded

  attributes do
    attribute :title, :string, public?: true
    attribute :columns, {:array, Demo.Blog.ContentColumn}, default: [], public?: true
  end
end

defmodule Demo.Blog.Post do
  use Ash.Resource, domain: Demo.Blog, data_layer: AshSqlite.DataLayer

  attributes do
    attribute :sections, {:array, Demo.Blog.ContentSection}, default: [], public?: true
  end

  actions do
    create :admin_create do
      accept [:title, :sections]
    end

    update :admin_update do
      require_atomic? false
      accept [:title, :sections]
    end
  end
end

The embedded resources need data_layer: :embedded; they are attribute types, not independently persisted records. The parent database column stores the tree, and both create and update actions must accept the root :sections attribute. Add the equivalent column through your Ash data-layer migration.

Configure the root field in the parent LiveResource. This is the complete DSL shape used by the demo:

field :sections do
  module Backpex.Fields.InlineCRUD

  child_fields do
    field :columns do
      module Backpex.Fields.InlineCRUD

      child_fields do
        field :heading

        field :target do
          module AshBackpex.Fields.Embedded

          child_fields do
            field :kind
            field :path
          end
        end
      end
    end
  end
end

AshBackpex infers type: :embed for both repeated embedded nodes and derives every child field from the resource at that depth. Relationship InlineCRUD remains limited to has_many and continues to infer type: :assoc; use the normal relationship field for other relationship cardinalities.

Embedded Parameters, Validation, and Empty Lists

The browser sends repeated fields as indexed maps, with controls at each repeated level. For example, a section with one column normalizes to the Ash attribute shape below; the *_order, *_delete, and *_move_* keys are form controls and do not reach the Ash action:

%{
  "sections" => [
    %{
      "title" => "Hero",
      "columns" => [
        %{
          "heading" => "Welcome",
          "target" => %{"kind" => "internal", "path" => "/welcome"}
        }
      ]
    }
  ]
}

AshBackpex applies moves and deletes, then turns every repeated indexed map into its ordered list form before calling the parent create or update action. A singular embedded node stays a map. An empty repeated node is normalized to [], so default: [] on embedded array attributes is important and an empty section or an empty root list remains editable after a validation rerender.

Each repeated node renders its own add, delete, move-up, and move-down controls. Control names are derived from the current nested form, so validation events operate on only that node while preserving persistent row identities and the submitted order. Singular embedded nodes render a fieldset and pass their immediate Phoenix form to child visibility, authorization, readonly, label, help-text, and error translation callbacks.

When Ash rejects a nested value, its error is reconstructed at the submitted array index and singular-field path on the next render. Keep the hidden _persistent_id values emitted by the form: they give unsaved rows stable LiveView identity while users add, delete, reorder, and correct invalid values. Ordering controls only determine the submitted list order; persist a position attribute in your own model if that order must survive a later reload.

Recursive embeds compose with relationship InlineCRUD. A parent can retain a has_many relationship field configured with type: :assoc and manage_relationship, alongside embedded fields configured with type: :embed. They normalize independently at each repeated depth.

This support is for typed embedded resources and ordinary fields. Conditional union or variant-specific forms remain outside this configuration model.