Skip to content

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-formset plugin installed
  • Layout API with Collection component
  • 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:

Collection('books',
    model=Book,
    fields=['title', 'isbn', 'published_date'],
)

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):

Collection('books',
    model=Book,
    fields=['title'],
    legend='Author Bibliography',  # Custom legend
)

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:

  1. Pagination: Show only recent items, with "Load more" button
  2. Filtering: Allow filtering before loading
  3. 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_siblings to 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:

<div class="collection-item">
    <span class="drag-handle"></span>
    <!-- Fields -->
</div>

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']

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:

# Show 3 empty book forms initially
Collection('books',
    fields=['title'],
    extra_siblings=3,
)

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

  • fields OR layout required (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