Inlines
Edit a child model's reverse-FK rows right on the parent's change form - add, edit, and delete children, saved atomically with the parent.
An inline lets you edit a child model's rows directly on its parent's change form. Open a Post, and right below its fields you get its Comments - add new ones, edit existing ones, tick a box to delete. When you hit Save, the parent and all its children are written in one transaction: if any child fails to save, the whole thing rolls back and nothing changes. Inlines come in tabular and stacked layouts.
When you'd reach for it
A child model whose rows only make sense in the context of one parent - order line-items, post comments, product variants - is tedious to manage as a separate top-level admin section. Declaring it as an inline keeps the parent and its children on one screen and one save.
Declare an inline
Inlines hang off the parent's AdminModel via .inlines(...). Each [InlineModel] names the child table, the FK column on the child that points back at the parent, and which child columns to surface:
use umbral_admin::{AdminPlugin, AdminModel, InlineModel, InlineKind}; let post = AdminModel::new("post").inlines(vec![ InlineModel::new("comment", "post", &["text", "rating"]),]); AdminPlugin::default().register(post)"comment" is the child SQL table. "post" is the FK column on comment - note that a ForeignKey<Post> field named post maps to a column named post (umbral does not append _id). &["text", "rating"] are the editable columns shown per row; the FK and primary key are managed for you and never rendered.
The minimal struct form still works for back-compat - InlineModel { model, fk_field, list_display } constructs with the other knobs defaulted - but prefer the ::new(...) constructor plus chainable setters.
Tabular vs stacked
Two layouts:
Knobs
InlineModel::new("comment", "post", &["text", "rating"]) .kind(InlineKind::Tabular) // Tabular (default) | Stacked .extra(2) // blank "add a child" rows (default 1) .can_delete(true) // per-row DELETE checkbox (default true) .readonly_fields(&["rating"]) // columns rendered, never writtenextra- how many empty rows to render for adding new children. An "Add another" button clones a blank row for more, with no JS framework dependency.can_delete- whether each existing child row gets a DELETE checkbox. Tick it and Save to remove that child.readonly_fields- child columns shown but never written back, even if the submitted body tries.
Atomic save
The save is the point. When you submit the parent change form:
- A transaction opens.
- The parent row is inserted (or updated), yielding its primary key.
- Each inline child is inserted / updated / deleted, with its FK set to that parent key.
- The transaction commits.
If any child write fails - a missing required field, a bad value for a typed column, a constraint violation - the transaction is dropped. Neither the parent change nor any child write persists. The form re-renders with your edits intact and a message naming the offending inline row. There's no half-saved state where the parent changed but a child didn't.
Where it works
Inlines render and save in both of the admin's edit surfaces, from the same declaration:
- The full-page change form (
/{table}/newand/{table}/{id}/edit) - the path the changelist's "Add" and "Edit" buttons link to. - The slide-over sheet - the HTMX right-side panel that opens when you create or edit a row inline on the changelist.
Both surfaces share one formset macro, so the child fields, the "Add another" button, and the DELETE checkbox look and behave identically. The save path is the same atomic transaction in either place: a bad child rolls back the parent and every sibling, and the sheet re-renders the slide-over (not a full page) with your edits and the error intact.
See also
- The plugin contract and admin design rationale live in
arch.mdanddocs/specs/. - Dashboard widgets - the other big admin customization surface.