Skip to content

Inline Editing

Let users tweak individual rows without leaving the list view. PowerCRUD renders inline form fields inside the table, swaps rows with HTMX, and keeps the action column focused on “Save / Cancel” while editing.

Use inline editing for a small number of quick, low-risk changes in a list. Keep the normal Edit form for larger or more complicated changes.


Prerequisites

  • Setup & Core CRUD basics completed.
  • HTMX enabled (use_htmx = True) and the standard PowerCRUD list template in use.
  • Optional but recommended: use_modal = True so the inline scripts and global spinners are already loaded.

1. Enable inline editing

For a first inline-editable screen, enable HTMX and name the editable fields. Each field must be an editable model field, be present on the actual form, and be visible in the list.

class BookCRUDView(PowerCRUDMixin, CRUDView):
    model = Book
    base_template_path = "core/base.html"

    use_htmx = True
    inline_edit_fields = ["title", "author", "published_date", "genres"]
    inline_edit_always_visible = True
    inline_edit_highlight_accent = "#14b8a6"
Legacy inline_edit_enabled compatibility

inline_edit_enabled is now legacy compatibility-only behavior. New code should configure inline editing only through inline_edit_fields.

Older views that still set inline_edit_enabled = True continue to work temporarily. If they do not define inline_edit_fields, PowerCRUD temporarily falls back to the resolved form fields to preserve the old behavior.

Downstream projects should migrate away from inline_edit_enabled.

inline_edit_fields both enables inline editing and controls which visible cells users can click. Leave it unset to disable inline editing for the view. The selected pack already supplies the triggers, Save/Cancel buttons, and inline-row-* events; you do not need template work unless you override the list partial.

Inline-edit rules and implementation details
  • Explicit inline_edit_fields entries must be editable model fields. If you include a field with editable=False, PowerCRUD raises a configuration error during view setup.
  • inline_edit_fields must match fields exposed by the actual form returned by get_form_class(). If you use a custom form_class, PowerCRUD still filters the inline list to fields that really exist on that form.
  • Stock inline editing still builds the full edit form, then reposts any non-rendered form fields as hidden inputs so inline saves behave like the normal edit form.
  • form_disabled_fields does not disable the same field inline. It remains an update-form feature only.
  • Only columns actually rendered in the list can be clicked inline, so a field must be both inline-configured and visible in the list to behave as an inline-editable cell.
  • inline_edit_always_visible defaults to True, which means editable cells show a subtle resting hint even before hover.
  • inline_edit_highlight_accent defaults to "#14b8a6". PowerCRUD derives the lighter resting tint, stronger hover/focus tint, and active-row highlight from that single hex accent automatically.
  • The mixin automatically injects inline metadata into the row payload and exposes two HTMX endpoints:
    • …-inline-row – swaps the display/form row and handles POST saves.
    • …-inline-dependency – rebuilds a single field widget for dependent dropdowns.

Styling the inline-edit affordance

Use the default configuration when the built-in teal hint is fine:

inline_edit_always_visible = True
inline_edit_highlight_accent = "#14b8a6"

To keep the stronger hover/focus cue but remove only the always-on resting tint:

inline_edit_always_visible = False

Hover and focus highlighting still remain active in this mode; only the persistent resting tint is removed.

To use a different accent, provide a hex color and let PowerCRUD derive the lighter and stronger variants automatically:

inline_edit_highlight_accent = "#3b82f6"

Notes:

  • Only hex colors are supported here: #rgb and #rrggbb.
  • This API intentionally keeps the surface small. If you need per-state color control beyond the single accent, override the list partial in your project.

2. Add dependencies when one field changes another

Skip this section unless one field should change the choices offered by another. Inline editing reuses the normal form machinery, so widget and queryset customisations carry over. For a dynamic dropdown, declare the shared queryset dependency once:

class BookCRUDView(PowerCRUDMixin, CRUDView):
    # …
    field_queryset_dependencies = {
        "genres": {
            "depends_on": ["author"],
            "filter_by": {"authors": "author"},
            "order_by": "name",
            "empty_behavior": "all",
        }
    }
  • depends_on lists the parent fields that drive the child queryset.
  • filter_by maps child queryset lookups to those parent form fields.
  • Each dependent field renders a placeholder + spinner; the JS helper issues a POST to the dependency endpoint and swaps the widget.
  • If the underlying form raises a validation error, the inline row re-renders with field errors and a forced-visible field popover for the first invalid inline widget. The popover is appended outside the table so scroll containers and narrow cells do not hide the message.

The same helpers drive both inline rows and lock-sensitive action buttons, so _build_inline_row_payload() contains everything the template needs (row id, inline URLs, lock metadata).

Cookbook: parent/child dropdowns that refresh inline

The most reliable pattern is:

  1. Add an explicit relation that describes which child records are allowed for each parent.
  2. Declare field_queryset_dependencies on the CRUD view.
  3. Use a custom form_class only for form concerns that remain outside the generic dependency rule.
  4. Let PowerCRUD resolve the parent field from bound POST data first, then fall back to the instance.

The sample app demonstrates this with Book.author -> Book.genres, where the allowed genres come from Author.genres:

Sample app dynamic inline field example
class BookCRUDView(PowerCRUDMixin, CRUDView):
    model = Book
    form_class = BookForm
    field_queryset_dependencies = {
        "genres": {
            "depends_on": ["author"],
            "filter_by": {"authors": "author"},
            "order_by": "name",
            "empty_behavior": "all",
        }
    }

    use_htmx = True
    inline_edit_fields = [
        "title",
        "author",
        "genres",
        "published_date",
        "bestseller",
        "isbn",
        "description",
    ]
class BookForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["genres"].required = False

Why this works:

  • The normal create/update form and the inline row share the same dependency rule.
  • The inline dependency endpoint rebuilds only the dependent field widget, but it reuses the same form pipeline as the regular edit form.
  • When the user changes author inline, PowerCRUD syncs the current row controls, posts the row state, rebuilds the genres widget, clears the stale selection, and swaps the constrained widget back into the row.

Use this pattern whenever a child dropdown should change immediately in response to another inline field.

For a fuller explanation of filter_by, multiple-parent mappings, and migration from older inline-only dependency patterns, see Forms.


3. Add locks and permissions when needed

Inline editing piggybacks on the same hooks the bulk/async flows use:

  • has_power_update_permission(request, obj) – broad update permission for PowerCRUD-owned update operations.
  • inline_edit_requires_perm – e.g. "sample.change_book".
  • inline_edit_allowed(obj, request) – custom per-row checks.
  • is_inline_row_locked(self, obj) / get_inline_lock_details(self, obj) – pair with AsyncManager to keep rows read-only while background jobs run.

PowerCRUD checks the broad update permission before inline-specific checks. Use inline_edit_allowed for inline-only restrictions after the user is otherwise allowed to update the row.

See the Hooks reference for the canonical contract details for these extension points.

When a guard fails:

  • The row swaps back to read-only and emits inline-row-locked or inline-row-forbidden.
  • Action buttons and extra per-row actions marked lock_sensitive are disabled automatically.
  • The list payload exposes _blocked_reason / _blocked_label so you can show tooltips or badges even before a user clicks.

The selected pack's built-in template also disables Save/Cancel buttons and shows a spinner while HTMX POSTs are in-flight, matching the regular object form behaviour.


4. Know the client-side events

inline-row-saved     # payload: {"pk": …}
inline-row-error     # payload: {"pk": …, "message": …, "field_errors": […], "non_field_errors": […]}
inline-row-locked    # payload: {"message": …, "refresh": {...}}
inline-row-forbidden # payload: {"message": …}
  • Listen for these events (e.g. via document.body.addEventListener) to show custom notices or refresh related widgets.
  • The inline helper script listens for inline-row-locked / inline-row-forbidden, clears the active state, and refreshes the affected row when a refresh payload is available.
  • The inline helper script also listens for inline-row-error, keeps the row open, and pins a field-level popover to invalid inline widgets.
  • When a row finishes saving, HTMX swaps the table row back to display mode and restores the previous column widths so the layout does not jump.

5. UX notes & troubleshooting

  • Fields you don’t expose inline – Stock inline editing reposts the rest of the full form as hidden inputs, so saves preserve non-rendered values and validate against the same form surface as regular edits. inline_preserve_required_fields remains as a defensive fallback for custom inline builders that still omit required inputs from POST data.
  • Column widths – editable columns reserve a small width buffer so swapping to a widget (with icons or date pickers) does not push neighbouring columns. Non-editable columns keep their natural width.
  • Single-select fields – eligible dropdowns are enhanced with Tom Select by default, so users can type to filter options inline without changing backend form contracts. Inline activation focuses the field and opens the dropdown when the selected cell is a searchable select. Disable globally with searchable_selects = False or per field with get_searchable_select_enabled_for_field().
  • Validation errors – field errors remain in the row markup for accessibility and fallback rendering. When the runtime successfully shows a forced-visible popover, it visually hides the duplicate inline error text with sr-only; if the popover cannot be created, the inline text stays visible. If you override the inline row partial, preserve the data-inline-field-error, data-inline-error-message, aria-invalid, and aria-describedby attributes so the package runtime can find the invalid widget.
  • Multi-select fields – both first-party packs use the compact multiselect described below for eligible ManyToMany fields.
  • HTMX targets – inline rows target themselves (hx-target="#pc-row-{{ pk }}") so partial updates do not reload the entire table.
  • Keyboard flow – the row automatically focuses the cell that triggered edit mode (or the first editable field) so users can start typing immediately. Text/number inputs are pre-selected on first focus so typing replaces the current value. Press Enter to trigger the same Save action as the button (except inside textareas), and Esc mirrors the Cancel button. <Tab> will tab between editable fields in the row.
  • Testing – unit tests can call _dispatch_inline_row() and _dispatch_inline_dependency() directly (see src/tests/test_inline_editing_mixin.py for a harness). Browser tests should assert the inline-row-* triggers fire correctly.

Compact ManyToMany controls

Eligible inline ManyToMany fields use the selected pack's compact searchable multiselect instead of a tall native select. The closed and open control keeps a stable N selected summary while the dropdown shows the individual options and their checked state.

  • Clicking an option selects or deselects it without closing the menu.
  • Clear-all removes the current selection while retaining ordinary Django multi-value submission.
  • The dropdown opens above the control when the viewport does not have enough room below, without recentering the row.
  • The control and detached menu inherit the table cell's configured typography and the inline highlight palette.
  • A dependency refresh replaces and reinitialises the same compact control through the normal inline lifecycle.
  • Successful save keeps the hidden native select hidden until HTMX removes the outgoing row, so there is no native-control flash.

The pack supplies this default presentation. A widget explicitly selected by the application remains application-owned as described in Forms.

Manual test checklist for dependent inline fields

  1. Open a row in inline mode.
  2. Change the parent dropdown.
  3. Confirm the child field clears immediately.
  4. Re-open the child dropdown without saving the row.
  5. Confirm only the allowed options are present.
  6. Save the row and confirm the same constraint still applies when reopening inline mode.

Persisting validated inline rows

After an inline row form validates, PowerCRUD routes the write through the same public persist_single_object(...) hook used by the standard object form flow.

For the canonical contract, see the Hooks reference.

If you want a more practical walkthrough of keeping normal form saves and inline saves on one shared service hook, see Persistence Hooks.

For inline saves:

  • mode is "inline"
  • instance is the object currently being edited in the row
  • the returned object is still the saved instance stored on self.object
  • after a successful save, PowerCRUD refreshes the current filtered list results instead of re-rendering only the row directly

By default, inline-save refresh keeps the current page, sort, filters, and page_size. If the saved row no longer belongs to the active filtered queryset, PowerCRUD drops page so the list refresh restarts from page 1. You can override that behavior per view with inline_save_refresh_policy:

  • "reset_if_filtered_out" – default; keep the current page unless the saved row falls out of the active filters
  • "keep_page" – always preserve the current page during inline-save refresh
  • "reset_page" – always drop the current page during inline-save refresh

Example:

class BookCRUDView(PowerCRUDMixin, CRUDView):
    def persist_single_object(self, *, form, mode, instance=None):
        book = super().persist_single_object(
            form=form,
            mode=mode,
            instance=instance,
        )
        InlineAuditService().record(book=book, mode=mode)
        return book

This lets downstream projects keep standard form saves and inline row saves on one shared single-object persistence hook.

Continue with Bulk editing (synchronous) if you also need row-level bulk operations, or jump ahead to Async Manager / Bulk editing (async) once you want background processing and conflict locks.