Repeating Child Forms with InlineCRUD
View SourceBackpex.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
endThe 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
endManage 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
endThe 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[]orcomments_move_down[], which contains the index selected by a move control.- A hidden
idinside each persisted comment form. - A hidden
_persistent_idthat 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
endThe 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
endAshBackpex 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.