Implementing Inline Editing
This is the plan for adding inline row editing to PowerCRUD’s list views.
Rationale
Inline editing lets operators tweak individual rows without breaking context or bouncing between detail pages. We already have most of the plumbing—field introspection, ModelForm generation, HTMX responses, and daisyUI styling. The missing piece is a cohesive row editor that reuses those assets, respects async conflict locks, and gives downstream projects a declarative way to opt in.
Objectives
- Inline edits swap a display row for a form that uses the same ModelForm widgets as modal/detail edits.
- HTMX handles all swaps and submissions; no edit controls render when
use_htmxresolves false. - Row saves remain synchronous but honor async conflict locks so users never edit records in-flight elsewhere.
- Dependent-field refresh (e.g., change service type → refresh asset group choices) works via declarative metadata.
- Permission checks align with existing view policy, allowing custom per-row guards.
- UX remains accessible: clear focus states, inline error placement, obvious save/cancel cues.
Constraints & Open Questions
- Inline editing is opt-in per CRUD view; default remains read-only rows.
- All inline forms submit the full row. Autosave-per-field stays out of scope for v1 to avoid partial validation issues.
- Most dependency rules need declarative metadata; model introspection can help but cannot cover downstream business logic.
- Rows locked by the async conflict manager must never show inline edit controls. If a lock appears mid-edit the save call reverts back to read-only mode with a warning.
- Determine whether we expose new template partials or keep everything in
object_list.htmlvia HTMX targets. - Downstream override points (templates, mixin methods) must stay stable so existing apps can customize safely.
Design Highlights
Inline Editing Gate
get_inline_editing()resolves to True only when HTMX is in use and the view opts in (e.g.,inline_edit_enabled = True).- Views can restrict scope with
inline_edit_fields(defaults to the view’s editable field set) and a callableinline_edit_allowed(obj, request)for per-row decisions. - Permission hooks (
inline_edit_requires_perm,inline_edit_allowed) run for both rendering and submission so unauthorized rows never expose a form.
Row Lifecycle & UX
- Users enter inline edit mode by clicking any editable cell. Editable cells use hover/focus styles and a pointer/text cursor so it feels deliberate rather than accidental.
- Clicking an editable cell swaps the row for a
<form>built from the standard ModelForm and daisyUI helpers. Only one row can be in edit mode at a time; trying to edit another row first prompts the user to save or cancel the active row. - Save + Cancel buttons replace the normal action column (where Edit/Delete previously lived) and stay visible until the row returns to read-only mode. Saving requires clicking the button (or a keyboard shortcut); clicking elsewhere does not auto-save.
- On Save, HTMX posts back to the same endpoint; success swaps in the updated display fragment, failure swaps the form with inline errors.
Hx-Triggerevents exposeinline-row-saved/inline-row-errorfor optional downstream listeners (e.g., flashing a message). - Cancel simply swaps back the read-only row partial and discards pending changes.
- Keyboard behavior mirrors the visual controls: Tab moves between fields, Enter/Space activates the focused control, and pressing Escape cancels the inline edit immediately.
Dependent Field Pattern
- Views can declare
inline_field_dependencies = {'asset_group': {'depends_on': ['service_type'], 'endpoint_name': 'assets:asset-group-choices'}}. We may auto-fill some cases via introspection (e.g.,limit_choices_tocallables) but downstream metadata remains the primary contract. - Templates emit
hx-trigger="change"on parent fields and target a small placeholder around the dependent widget. - Default dependency endpoint reuses FormMixin to rebuild just the child field with a filtered queryset so validation stays consistent.
Conflict & Permission Enforcement
- Before rendering edit controls, we reuse
AsyncMixinhelpers to detect locks; locked rows stay read-only with a plain warning (no edit button shown). - Inline save endpoint rechecks locks; if detected, it responds with an HTMX swap showing a conflict notice and reverts to read-only mode.
- Permission hooks run in both render and submit paths so unauthorized rows never expose a form.
Plan
- ✅ Confirm scope & UX
- ✅ Align on row-level saves, HTMX-only requirement, and dependency metadata contract.
- ✅ Document that async conflict locks must hide inline editors rather than relying on failure paths.
- ✅ API surface & mixin updates
- ✅ Add new CoreMixin attributes:
inline_edit_enabled,inline_edit_fields,inline_field_dependencies,inline_edit_requires_perm, andinline_edit_allowed. - ✅ Update
PowerCRUDMixinValidatorso the new settings are type-checked and defaulted. - ✅ Implement helper methods (
get_inline_editing(),get_inline_edit_fields(),get_inline_dependencies(),can_inline_edit(obj, request)) so downstream views can override behavior cleanly. - ✅ Expose FormMixin hooks to build inline forms via the existing
get_form_class()/get_form_kwargs()pipeline. - ✅ Provide TableMixin (or new inline mixin) helpers that add inline config to the template context (editable cells, dependency metadata, row targets).
- ✅ Add URL/HTMX helpers (possibly via UrlMixin/HtmxMixin) to resolve the inline row endpoint and per-row HTMX targets.
- ✅ Integrate permission checks using
inline_edit_requires_permand custom callables before rendering inline controls or accepting saves. - ✅ Surface async conflict status via a helper (e.g.,
is_row_locked(obj)) so templates and save views can share the same logic.
- ✅ Add new CoreMixin attributes:
- ✅ Template & HTMX wiring
- ✅ Annotate row containers with predictable IDs so HTMX can target individual rows (
pc-row-{{ pk }}) and swap entire rows when needed. - ✅ In
partial/list.html, mark cells corresponding toinline_edit.fieldswith hover/focus styles, keyboard focusability, andhx-getto fetch the inline form. - ✅ Replace the action column with Save/Cancel buttons while a row is in edit mode; ensure only one row can be active.
- ✅ Wire editable cells with
hx-triggerfor click + Enter/Space; add hover cues and keyboard-friendly focus styles. - ✅ Add Escape handling so
keyup[Escape]cancels the inline form and restores the display row (scaffolded in the inline form partial). - ✅ Create the inline form partial so HTMX swaps stay scoped.
- ✅ Emit HTMX attributes for dependent fields (
hx-trigger="change", target placeholders) based oninline_edit.dependencies. - ✅ Ensure keyboard shortcuts (Tab, Enter, Escape) map cleanly to inline edit behavior with appropriate ARIA/focus handling.
- ✅ Annotate row containers with predictable IDs so HTMX can target individual rows (
- ✅ Dependency endpoint & HTMX wiring
- ✅ Add inline row HTMX endpoints: GET returns the inline form snippet for a row, POST validates/saves and returns either the refreshed display row or the form with errors (plus HTMX triggers).
- ✅ Register URL patterns (
…-inline-row,…-inline-dependency) viaUrlMixinso every CRUD view automatically exposes the endpoints. - ✅ Implement the dependency refresh endpoint that rebuilds just the child field widget using the standard form pipeline and returns that fragment.
- ✅ Wire the inline form partial with
hx-post/hx-getattributes for Save/Cancel andhx-trigger="keyup[Escape]"; add a tiny JS helper to enforce one active row at a time and fire dependency refresh requests.
- ☐ Conflict & permission handling
- ✅ Surface lock/permission metadata in the list payload so templates suppress inline triggers and show “read-only” cues when
is_inline_row_lockedor permission hooks fail. - ✅ Re-check locks/permissions inside the inline-row GET/POST endpoints and return HTMX-friendly 4xx responses that swap the row back plus emit
inline-row-locked/inline-row-forbiddentriggers. - ✅ Thread async-lock details (owner, timestamp) into those responses so the UI can explain why editing stopped, and add a helper to re-fetch the display fragment when conflicts clear.
- ✅ Extend the inline JS to listen for the new triggers, clear the active-row state, and show toast/banner feedback; ensure dependency listeners are removed when a row falls back to read-only.
- ✅ Surface lock/permission metadata in the list payload so templates suppress inline triggers and show “read-only” cues when
- ✅ Sample app inline coverage
- ✅ Enable inline editing on
BookCRUDViewwith a curated field list plus author→genre dependency metadata so the sample can demonstrate the feature. - ✅ Confirm the sample DaisyUI template inherits the shared inline partials/JS so no extra wiring is required.
- ✅ Wire dependency metadata so changing
authorinline refreshes thegenreswidget via the dependency endpoint. - ☐ Give the sample data model a real dependency (e.g.,
Author.available_genres) so the inline dropdown filter is verifiably scoped by the parent field. 6.1 ☐ Inline UX polish - ✅ Surface inline validation failures directly in the row so users see field + non-field errors without relying on console logs; the inline-row-error trigger now focuses the row while the JS banner echoes the same message.
- ✅ Tidy lock presentation: suppress the giant lock badges, grey out Edit/Delete/lock-sensitive actions when a row is locked, and move the “Locked by…” copy into the tooltip so only affected rows show the notice.
- ✅ Add visual progress to saves: toggle a spinner + disabled state on both the inline Save button and the object form Save button while HTMX posts, so the 2‑second sample
Book.save()delay has explicit feedback without layout jumps.
- ✅ Enable inline editing on
- ☐ Tests
- Backend / pytest
- ✅ Stand up lightweight InlineEditingMixin test views/forms so we can hit the HTMX inline-row endpoint for: GET form render, POST success swap (emits
inline-row-saved), validation errors (422 +inline-row-error), and guard fallbacks (locked + forbidden states returning display rows). - ✅ Cover
_dispatch_inline_dependencyhappy path and failure modes (missingfield, unknown field, pk lookups) to prove dependent widgets are rebuilt correctly without touching any bootstrap5 templates. - ✅ Exercise helper APIs (
get_inline_edit_fields,get_inline_field_dependencies,_get_inline_lock_metadata,get_inline_context) to ensure inline_config fed into daisyUI templates includes resolved row IDs, dependency URLs, and lock metadata even when async managers throw. - ✅ Render the daisyUI
object_listpartial with stub data to assertdata-inline-*attributes, Save/Cancel button states, and dependency placeholders only appear when inline editing is enabled. - ✅ Add a regression test that seeds a simulated async lock (via the sample async manager or a stub cache) and verifies list payload + inline endpoint both respect the lock.
- ✅ Stand up lightweight InlineEditingMixin test views/forms so we can hit the HTMX inline-row endpoint for: GET form render, POST success swap (emits
- Playwright (daisyUI only)
- ✅ Happy-path inline edit on a sample Book row: open inline mode, change a field, save, wait for
inline-row-saved, and assert the row text updates while only one row stays active. - ✅ Validation failure flow: submit an empty required field inline, expect inline error text + form persistence + guard reset after fixing the data.
- ✅ Guard focus behavior: start editing row A, attempt row B, assert focus returns to A; cancel/refetch list (pagination/filter) and confirm a new row can be edited immediately.
- ✅ Happy-path inline edit on a sample Book row: open inline mode, change a field, save, wait for
- Order of work
- Backend: build inline test doubles + pytest coverage listed above, keeping focus on daisyUI paths and excluding any bootstrap5 templates.
- Template assertions: render the object_list/inline partials to lock in the expected
data-inline-*structure before UI automation relies on them. - Playwright: extend the existing suite with the inline scenarios (happy path, validation, guard) and reuse current fixtures so we can run
pytest -m playwright. - Verification: run
pytest(non-Playwright) thenpytest -m playwright, and capture coverage deltas againstcoverage.xmlfor the inline mixin + templates.
- Backend / pytest
- ☐ Docs
- ✅ Add cookbook-style docs showing configuration snippets, dependency examples, lock states, and the new inline JS hooks.
- ✅ Call out that inline forms reuse the view’s
form_class(or generated form), so any custom fields/widgets/omissions carry over; inline field lists must stay aligned with whatever that form actually exposes.
- ☐ Future UX polish
- ☐ Explore lighter-weight discoverability (e.g., helper tooltip, iconography, first-run banner) so users realize cells are editable without guesswork.
- ✅ URGENT Fix the horrible jumping around of the whole table that happens when you click to open a row for inline editing.
📝 Sample dependency follow-up: Right now
BookCRUDView’s author→genre dependency is purely declarative—the models never limit genres per author. We still need a lightweight relationship (like anAuthor.available_genresM2M) plus form/queryset filtering so the inline widget visibly narrows its options when the parent field changes. Once that exists, the inline demo becomes self-validating and doubles as coverage for the dependency refresh endpoint.