Inline Editing Guide - djadmin-formset¶
Plugin: djadmin-formset
Version: 0.1.0 (Alpha)
Last Updated: October 21, 2025
Overview¶
Inline editing allows you to edit related objects directly within a parent form, similar to Django admin's inline functionality but with enhanced features powered by django-formset. Users can add, edit, remove, and reorder related objects without leaving the page.
Prerequisites¶
djadmin-formsetplugin installed- Layout API with
Collectioncomponent - Related model with ForeignKey or ManyToMany relationship
Basic Usage¶
Simple Inline Collection¶
Use the Collection component to display related objects inline:
from djadmin import ModelAdmin, register, Layout, Field, Collection
from myapp.models import Author, Book
@register(Author)
class AuthorAdmin(ModelAdmin):
layout = Layout(
Field('name'),
Field('birth_date'),
# Inline books collection
Collection('books',
model=Book,
fields=['title', 'isbn', 'published_date'],
),
)
What users see: - Form for author fields (name, birth_date) - List of existing books (editable) - "Add another book" button - "Remove" button for each book
Collection with Field List¶
The simplest approach uses a list of field names:
Django-formset automatically creates a form for these fields.
Collection with Custom Layout¶
For more control, use a nested Layout:
from djadmin import Row
Collection('books',
model=Book,
layout=Layout(
Field('title'),
Row(
Field('isbn', css_classes=['flex-1']),
Field('published_date', css_classes=['flex-1']),
),
),
)
This gives you full control over field ordering, rows, fieldsets, etc.
Collection Configuration¶
Setting Min/Max Siblings¶
Control how many related objects can be added:
Collection('books',
model=Book,
fields=['title', 'isbn'],
min_siblings=1, # Require at least 1 book
max_siblings=10, # Allow maximum 10 books
extra_siblings=2, # Show 2 empty forms initially
)
Behavior:
- min_siblings: Validation fails if fewer items
- max_siblings: "Add" button disabled when reached
- extra_siblings: Number of empty forms shown initially (default: 1)
Sortable Collections¶
Enable drag-and-drop ordering:
Collection('books',
model=Book,
fields=['title', 'isbn'],
is_sortable=True, # Enable drag-and-drop
)
Features:
- Drag handle appears on each item
- Drag to reorder
- Order saved to order field (or custom field)
Requirements:
- Model must have an integer field for ordering (e.g., order, position, sort_order)
- Field should be editable=False and db_index=True
# models.py
class Book(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
title = models.CharField(max_length=200)
# For sortable collections
order = models.IntegerField(default=0, editable=False, db_index=True)
class Meta:
ordering = ['order']
Custom Legend¶
Override the default legend (model verbose_name_plural):
Default legend is Book.verbose_name_plural (e.g., "Books").
Advanced Examples¶
Example 1: Author with Books¶
@register(Author)
class AuthorAdmin(ModelAdmin):
layout = Layout(
Fieldset('Personal Information',
Row(
Field('first_name', css_classes=['flex-1']),
Field('last_name', css_classes=['flex-1']),
),
Field('birth_date'),
Field('biography', widget='textarea'),
),
Fieldset('Publications',
Collection('books',
model=Book,
layout=Layout(
Field('title'),
Row(
Field('isbn', css_classes=['flex-1']),
Field('published_date', css_classes=['flex-1']),
),
Field('description', widget='textarea'),
),
is_sortable=True,
min_siblings=0,
max_siblings=50,
),
),
)
Example 2: Order with Line Items¶
@register(Order)
class OrderAdmin(ModelAdmin):
layout = Layout(
Fieldset('Order Information',
Field('customer'),
Field('order_date'),
Field('status'),
),
Fieldset('Line Items',
Collection('items',
model=OrderItem,
layout=Layout(
Field('product'),
Row(
Field('quantity', css_classes=['flex-1']),
Field('unit_price', css_classes=['flex-1']),
Field('total',
calculate='.quantity * .unit_price',
css_classes=['flex-1']
),
),
),
min_siblings=1, # At least one item required
),
),
Fieldset('Totals',
Field('subtotal', widget='text', attrs={'readonly': True}),
Field('tax', widget='text', attrs={'readonly': True}),
Field('total', widget='text', attrs={'readonly': True}),
),
)
Example 3: Nested Collections¶
Collections can be nested (collection within collection):
@register(Customer)
class CustomerAdmin(ModelAdmin):
layout = Layout(
Field('name'),
Field('email'),
# Level 1: Customer addresses
Collection('addresses',
model=Address,
layout=Layout(
Field('street'),
Field('city'),
Field('state'),
# Level 2: Contact phones for this address
Collection('contacts',
model=Contact,
fields=['phone', 'contact_type'],
min_siblings=0,
max_siblings=5,
),
),
min_siblings=1,
),
)
Note: Deeply nested collections can impact performance and UX. Keep nesting to 2-3 levels maximum.
Example 4: Collection with Conditional Fields¶
Combine collections with conditional logic:
@register(Product)
class ProductAdmin(ModelAdmin):
layout = Layout(
Field('name'),
Field('product_type'),
Collection('variants',
model=ProductVariant,
layout=Layout(
Field('sku'),
Field('size'),
# Conditional fields within collection
Field('weight',
show_if=".product_type === 'physical'"
),
Field('download_link',
show_if=".product_type === 'digital'"
),
),
),
)
Note: In the conditional expression, .product_type refers to the parent form's field, not the collection item's field.
ForeignKey vs ManyToMany¶
ForeignKey Relationships¶
Most common use case - each related object belongs to one parent:
# models.py
class Book(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
title = models.CharField(max_length=200)
# djadmin.py
@register(Author)
class AuthorAdmin(ModelAdmin):
layout = Layout(
Field('name'),
Collection('books', # Uses related_name
model=Book,
fields=['title'],
),
)
ManyToMany Relationships¶
For many-to-many relationships, use a through model:
# models.py
class Author(models.Model):
name = models.CharField(max_length=200)
class Book(models.Model):
title = models.CharField(max_length=200)
authors = models.ManyToManyField(Author, through='BookAuthor')
class BookAuthor(models.Model):
"""Through model for book-author relationship."""
book = models.ForeignKey(Book, on_delete=models.CASCADE)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
role = models.CharField(max_length=100) # e.g., "Lead Author", "Contributor"
order = models.IntegerField(default=0)
class Meta:
ordering = ['order']
# djadmin.py
@register(Book)
class BookAdmin(ModelAdmin):
layout = Layout(
Field('title'),
# Edit through model as collection
Collection('bookauthor_set', # Use the reverse relation
model=BookAuthor,
layout=Layout(
Field('author'),
Field('role'),
),
is_sortable=True,
),
)
Custom Form Classes¶
Provide a custom form class for collection items:
# forms.py
from django import forms
from myapp.models import Book
class BookInlineForm(forms.ModelForm):
"""Custom form for book collection items."""
class Meta:
model = Book
fields = ['title', 'isbn', 'published_date']
def clean_isbn(self):
"""Custom validation for ISBN."""
isbn = self.cleaned_data['isbn']
# Custom validation logic
return isbn
# djadmin.py
from myapp.forms import BookInlineForm
@register(Author)
class AuthorAdmin(ModelAdmin):
layout = Layout(
Field('name'),
Collection('books',
model=Book,
form_class=BookInlineForm, # Custom form
fields=['title', 'isbn', 'published_date'],
),
)
Validation¶
Collection-Level Validation¶
Validate the entire collection:
# forms.py
class AuthorForm(forms.ModelForm):
def clean(self):
"""Validate author form including books collection."""
data = super().clean()
# Access collection data
books = data.get('books', [])
if len(books) < 1:
raise forms.ValidationError("Author must have at least one book")
# Validate uniqueness across collection
titles = [book.get('title') for book in books]
if len(titles) != len(set(titles)):
raise forms.ValidationError("Book titles must be unique")
return data
Item-Level Validation¶
Use model validation or form clean methods:
# models.py
class Book(models.Model):
# ...
def clean(self):
"""Validate individual book."""
if self.published_date and self.published_date > date.today():
raise ValidationError("Publication date cannot be in the future")
Or in the custom form:
class BookInlineForm(forms.ModelForm):
def clean_title(self):
title = self.cleaned_data['title']
if len(title) < 3:
raise forms.ValidationError("Title must be at least 3 characters")
return title
Performance Considerations¶
Loading Large Collections¶
For models with many related objects (100+), consider:
- Pagination: Show only recent items, with "Load more" button
- Filtering: Allow filtering before loading
- Async loading: Load collection data via AJAX
# For now, limit with max_siblings
Collection('books',
model=Book,
fields=['title'],
max_siblings=50, # Prevent performance issues
)
Database Queries¶
Collections trigger additional queries. Optimize with select_related/prefetch_related:
# In your ModelAdmin
def get_queryset(self, request):
"""Optimize queries for collections."""
qs = super().get_queryset(request)
return qs.prefetch_related('books') # Reduce queries
Client-Side Performance¶
Large collections can slow down the browser:
- Limit
max_siblingsto reasonable numbers (< 100) - Use pagination for large datasets
- Consider alternative UI (e.g., autocomplete + table view)
Styling and UX¶
Collection CSS Classes¶
Collections are rendered with these default classes:
<div class="collection space-y-4">
<div class="collection-item p-4 border rounded">
<!-- Item fields -->
</div>
<!-- More items -->
<button class="btn-add">Add another</button>
</div>
Customize via renderer:
from djadmin_formset.renderers import DjAdminFormRenderer
class CustomRenderer(DjAdminFormRenderer):
collection_css_classes = 'my-collection-wrapper'
collection_item_css_classes = 'my-collection-item'
button_css_classes = 'my-add-button'
Add/Remove Buttons¶
Buttons are automatically rendered:
- Add button: "Add another {model_name}"
- Remove button: "×" or "Remove" (per item)
Customize button text via JavaScript or CSS:
/* Hide default remove button text */
.collection-item .btn-remove {
font-size: 0;
}
.collection-item .btn-remove::before {
content: "Delete";
font-size: 1rem;
}
Drag Handle¶
For sortable collections, a drag handle appears:
Style it:
.drag-handle {
cursor: move;
color: #999;
margin-right: 0.5rem;
}
.drag-handle:hover {
color: #333;
}
Troubleshooting¶
Collection Not Appearing¶
Symptom: No collection shown, or warning message
Checklist:
1. ✅ Plugin installed? 'djadmin_formset' in INSTALLED_APPS
2. ✅ Model has related_name? Check ForeignKey definition
3. ✅ Correct related_name in Collection?
Debug:
# Check relationship exists
author = Author.objects.first()
print(hasattr(author, 'books')) # Should be True
print(author.books.count())
Can't Add/Remove Items¶
Symptom: Buttons disabled or not working
Check:
1. JavaScript loaded? Check browser console
2. Max siblings reached? Check max_siblings setting
3. Permissions? User has add/delete permission for related model
Sorting Not Working¶
Symptom: Can't drag items or order not saved
Checklist:
1. ✅ is_sortable=True in Collection?
2. ✅ Model has order field (or similar)?
3. ✅ Field is integer and editable=False?
# Correct order field
class Book(models.Model):
order = models.IntegerField(default=0, editable=False, db_index=True)
class Meta:
ordering = ['order']
Related Objects Not Saving¶
Symptom: Collection items disappear after save
Common issue: Missing ForeignKey in form data
Solution: Ensure ForeignKey is handled automatically by django-formset:
# DON'T manually include the foreign key field
Collection('books',
fields=['title', 'isbn'], # DON'T include 'author'
)
# django-formset handles the relationship automatically
Validation Errors¶
Symptom: Can't save even though data looks correct
Debug:
# Check form errors
form = FormCollectionClass(data=request.POST)
if not form.is_valid():
print(form.errors) # See all errors
print(form.non_field_errors()) # Collection-level errors
Best Practices¶
1. Limit Collection Size¶
Don't show hundreds of items inline:
# Good - reasonable limit
Collection('books', model=Book, fields=['title'], max_siblings=50)
# Avoid - potential performance issues
Collection('books', model=Book, fields=['title'], max_siblings=1000)
2. Use Clear Field Labels¶
Make it obvious what fields do:
Collection('books',
layout=Layout(
Field('title', label='Book Title', required=True),
Field('isbn', label='ISBN-13', help_text='13-digit ISBN'),
),
)
3. Provide Sensible Defaults¶
Use extra_siblings to show empty forms:
4. Order Matters¶
For sortable collections, ensure proper ordering:
class Book(models.Model):
order = models.IntegerField(default=0, editable=False, db_index=True)
class Meta:
ordering = ['order'] # Important!
def save(self, *args, **kwargs):
# Auto-assign order if not set
if not self.order and self.author_id:
max_order = Book.objects.filter(author=self.author).aggregate(
Max('order')
)['order__max'] or 0
self.order = max_order + 1
super().save(*args, **kwargs)
5. Test Collection Forms¶
Test adding, editing, removing, and reordering:
def test_author_books_collection(admin_client, author_factory, book_factory):
"""Test inline book editing."""
author = author_factory()
# Test adding books
# Test editing existing books
# Test removing books
# Test reordering (if sortable)
6. Optimize Database Access¶
Use prefetch_related for collections:
class AuthorAdmin(ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.prefetch_related('books')
API Reference¶
Collection Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str | Required | Related field name |
model |
Model | Required | Related model class |
fields |
List[str|Field] | None | Field names or Field objects |
layout |
Layout | None | Custom layout for items |
min_siblings |
int | 0 | Minimum number of items |
max_siblings |
int | 1000 | Maximum number of items |
extra_siblings |
int | 1 | Number of empty forms shown |
is_sortable |
bool | False | Enable drag-and-drop ordering |
legend |
str | None | Custom legend text |
form_class |
Type | None | Custom form class |
Constraints¶
- ✅
fieldsORlayoutrequired (not both) - ✅ Model must have relationship to parent (ForeignKey or through table)
- ✅ If
is_sortable=True, model needs order field
Migration from Django Admin Inlines¶
Django Admin Inline¶
# Django admin (old)
from django.contrib import admin
class BookInline(admin.TabularInline):
model = Book
fields = ['title', 'isbn']
extra = 2
max_num = 10
min_num = 1
class AuthorAdmin(admin.ModelAdmin):
inlines = [BookInline]
djadmin-formset Collection¶
# djadmin-formset (new)
from djadmin import ModelAdmin, register, Layout, Field, Collection
@register(Author)
class AuthorAdmin(ModelAdmin):
layout = Layout(
# ... author fields ...
Collection('books',
model=Book,
fields=['title', 'isbn'],
extra_siblings=2, # Was 'extra'
max_siblings=10, # Was 'max_num'
min_siblings=1, # Was 'min_num'
),
)
Key differences: - ✅ More flexible layout options - ✅ Built-in client-side validation - ✅ Drag-and-drop ordering - ✅ Conditional and computed fields - ✅ Better UX (no page refresh)
See Also¶
- Conditional Fields Guide - Use with collections
- Computed Fields Guide - Calculate totals in collections
- Layout Integration Guide - Overall architecture
- django-formset Collections