Skip to content

feat: replace x-editable with HTMX for inline editing - #2847

Open
hasansezertasan wants to merge 23 commits into
pallets-eco:masterfrom
hasansezertasan:feat/replace-xeditable-with-htmx
Open

feat: replace x-editable with HTMX for inline editing#2847
hasansezertasan wants to merge 23 commits into
pallets-eco:masterfrom
hasansezertasan:feat/replace-xeditable-with-htmx

Conversation

@hasansezertasan

@hasansezertasan hasansezertasan commented Mar 29, 2026

Copy link
Copy Markdown
Member

Summary

  • Replace the unmaintained x-editable library with HTMX for theme-agnostic inline editing in list views
  • Net result: -702 lines (249 added, 951 removed)
  • The column_editable_list API is unchanged — no user-facing breaking changes

Fixes #1615

Changes

Component Before After
Widget XEditableWidget (125 lines, 12+ field type mappings) HTMXEditableWidget (30 lines, field-agnostic)
JS ~70 lines x-editable init code ~20 lines HTMX afterSwap handlers
Vendor x-editable CSS + JS (~670 lines) HTMX 2.0.8 min.js (14KB gzipped)
Endpoints POST /ajax/update/ → plain text GET /ajax/edit/ (new) + POST /ajax/update/ → HTML fragments

How it works

  1. User clicks an editable cell → HTMX sends GET /ajax/edit/?pk=X&field=Y
  2. Server returns an edit form HTML fragment → HTMX swaps it into the <td>
  3. User edits and presses Enter → HTMX sends POST /ajax/update/
  4. Server validates, saves, returns the updated display fragment → HTMX swaps it back
  5. Cancel (Escape key or button) restores the original content client-side (no round-trip)

Why HTMX

  • Theme-agnostic: No CSS framework dependency — works with Bootstrap 4, Bootstrap 5, Tabler, or any future theme
  • Simpler architecture: Server renders everything, client just swaps HTML fragments
  • Less JS to maintain: Field-type logic moves from client-side JS to server-side WTForms rendering
  • Active project: HTMX is actively maintained vs x-editable which hasn't been updated in years

Test plan

  • All existing SQLAlchemy editable list tests pass (165 passed)
  • All existing Peewee editable list tests pass (11 passed)
  • New test_ajax_edit_endpoint covers: valid field, non-editable field (404), non-existent record (404), no column_editable_list (404)
  • Validation errors re-render edit form with error markup
  • Special primary keys (strings with hyphens) work correctly
  • CSRF token conditional guard works with both SecureForm and BaseForm
  • No x-editable references remain in production code
  • Manual testing with the demo script below

Manual testing

Run the "sqla_column_editable" example to test inline editing interactively.

What to test manually

  1. Click to edit: Click any cell in Name/Email/Active columns → should show inline input
  2. Save: Change value, press Enter or click ✓ → value updates without page reload
  3. Cancel: Press Escape or click ✗ → reverts to original value
  4. Boolean field: Click Active column → should show Yes/No dropdown
  5. Validation: Try submitting an empty required field → should show error inline

@hasansezertasan
hasansezertasan marked this pull request as draft March 29, 2026 08:14
@hasansezertasan
hasansezertasan force-pushed the feat/replace-xeditable-with-htmx branch 2 times, most recently from 7567a9f to 7dd7c63 Compare March 29, 2026 08:56
@hasansezertasan
hasansezertasan requested review from ElLorans and samuelhwilliams and removed request for ElLorans March 29, 2026 09:11
@hasansezertasan

Copy link
Copy Markdown
Member Author

I'd be more into showing a modal for update forms.

@hasansezertasan
hasansezertasan force-pushed the feat/replace-xeditable-with-htmx branch 2 times, most recently from 9929fd3 to 7c29db5 Compare March 29, 2026 09:25
@hasansezertasan
hasansezertasan marked this pull request as ready for review March 29, 2026 09:29
@ElLorans

Copy link
Copy Markdown
Collaborator

Wow!! You say test passed but how many are testing the actual code changes?

@hasansezertasan
hasansezertasan force-pushed the feat/replace-xeditable-with-htmx branch 2 times, most recently from 4d943b5 to 0d5b90e Compare March 29, 2026 16:54
@ElLorans

Copy link
Copy Markdown
Collaborator

Also, this looks like a breaking change to me. If that's the case should either release this in 3.0, or provide a variable/parameter to switch.

@hasansezertasan

Copy link
Copy Markdown
Member Author

Wow!! You say test passed but how many are testing the actual code changes?

Could you please provide more detailed information 🤓?

Also, this looks like a breaking change to me. If that's the case should either release this in 3.0, or provide a variable/parameter to switch.

I taught the same. The breaking change to me seemded like "XEditableWidget", so I did a quick search for XEditableWidget on GitHub but couldn't find any implementor worth mentionable.

Is it possible to determine if it's a breaking change or not?

@hasansezertasan

hasansezertasan commented Mar 30, 2026

Copy link
Copy Markdown
Member Author

Some LLM:


I did a GitHub-wide search for XEditableWidget imports outside of flask-admin — found zero external usage.

The backwards compatibility alias is already in place:

# flask_admin/model/widgets.py:113
XEditableWidget = HTMXEditableWidget

So from flask_admin.model.widgets import XEditableWidget still works and returns HTMXEditableWidget.

The one theoretical breaking case: someone who subclassed XEditableWidget and overrode get_kwargs() (the old method that handled select/source field-type mappings). That method no longer exists since the new widget is field-agnostic — the server renders the proper input via WTForms. But given zero external implementations found, this seems safe.

The alias can be removed with a deprecation warning in a future major version if desired.

@samuelhwilliams

Copy link
Copy Markdown
Collaborator

Comment from the sideline - sorry - none of this is yet me weighing in on whether I support this change or not (or have even understood it yet!).

I did a GitHub-wide search for XEditableWidget imports outside of flask-admin — found zero external usage.

I did a non-LLM GitHub-wide search for this and I did find some external uses, eg:

That said, these codebases haven't been touched in a while.

I think should recognise that this isn't a backwards-compatible change and decide what level of risk tolerance we have for doing this without a full deprecation cycle. This comment isn't meant to steer strongly in either direction. You can decide that we prioritise our own speed in merging this over breaking one or two people, or you can decide that we try to stick with a stricter deprecation policy.

Do you have thoughts on how much overhead we're looking at if we deprecate this through adding a new component and leaving the existing XEditableWidget untouched?

@samialfattani

samialfattani commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

maybe the right question is, why we are stricting to deprecate something that is already not supported, not functioning well, not compatible for future UIs ? to me, our users would be happy if we provide them a better, stable, and permenant solution with a little cost of breaking change.
to the best of my knowledge, XEditableWidget depends on JQuery which is inteded to be removed from flask-admin .

@samuelhwilliams

samuelhwilliams commented Mar 31, 2026

Copy link
Copy Markdown
Collaborator

Because we are supporting it by having it in Flask-Admin. When a project takes on a dependency it's an implicit commitment to supporting that for our users, even if it is deprecated upstream. It's not fun for users when projects make breaking changes without giving adequate warning or time to migrate.

In my opinion this is simply the cost of providing stable software for an ecosystem.

I think there's potential to say the benefits of just swapping out directly outweigh the risks/disruption for users, but that isn't a decision to make without some consideration or understanding of the impact.

None of this is to say that I couldn't be convinced that just doing a straight swap here will be 'fine', so consider all of this commentary/conversation rather than edict.

@hasansezertasan

Copy link
Copy Markdown
Member Author

Comment from the sideline - sorry - none of this is yet me weighing in on whether I support this change or not (or have even understood it yet!).

Actually, the issues I had with x-editable at #2444 motivated me to work on this.

I've used skycyclone/x-editable over there, but that hasn't received any updates in the last 5 years either.

That got me thinking about alternatives, and I gave HTMX a try — it worked! 🥂 I did have to add some CSS to make it look a bit more polished, though.

I believe this work is a stepping stone toward better custom theme support.

I think should recognise that this isn't a backwards-compatible change and decide what level of risk tolerance we have for doing this without a full deprecation cycle. This comment isn't meant to steer strongly in either direction. You can decide that we prioritise our own speed in merging this over breaking one or two people, or you can decide that we try to stick with a stricter deprecation policy.

After giving it more thought, I agree with you — this is not a backwards-compatible change. I think we should discuss the deprecation policy and our vision for the user interface further before moving forward.

Do you have thoughts on how much overhead we're looking at if we deprecate this through adding a new component and leaving the existing XEditableWidget untouched?

The idea that led me to this PR: dropping Bootstrap 4 is overhead in itself.

I think we should talk about this topic thoroughly — what we want to do, where we want to go, and what we want to achieve.

@ElLorans

Copy link
Copy Markdown
Collaborator

I think making this not a breaking change is not possible (correct me if I am wrong), but I agree the "breaking" is minor.
Here are the alternatives I see, sorted by my preferences:

  1. We add it with tabler and/or bootstrap 5 (@princerb was working on something similar in [Theme] New theme: FomanticUI [beta] #2643 ) as an alternative to bootstrap 4
  2. We bring it in 3.0
  3. Bring it in the next minor release, accept the risk of the breaking

@hasansezertasan

Copy link
Copy Markdown
Member Author

After thinking about this more and reading everyone's feedback, I'd like to propose Option 1 with a twist.

Instead of tying the HTMX inline editing to a BS5/tabler theme (which depends on PR #2643), I'm proposing a "vanilla" theme — a dependency-free foundation that uses only semantic HTML, minimal custom CSS, and HTMX as the sole JS dependency. No jQuery, no Bootstrap, no Font Awesome.

I explored this idea through a brainstorming session with Claude Code (Opus), where I guided the design decisions and it helped me think through the architecture and write up the spec.

Why vanilla?

  • BYO Theme foundation — The vanilla theme outputs clean semantic HTML that works as a starting point for any CSS framework: BS2-5, Tabler, Tailwind, PicoCSS, or custom. Flask-admin renders HTML, you bring the styles.
  • Testing baseline — A minimal, predictable theme that Playwright tests can target without fighting Bootstrap's dynamic classes, animations, or JS timing.
  • Migration path clarity — By building a fully functional theme without BS4, we get concrete answers about what breaks and what the migration looks like.
  • Proves HTMX viability — Inline editing, modals (via native <dialog>), and all interactive features work with HTMX + ~175 lines of vanilla JS, replacing jQuery + Bootstrap JS + Select2 + 8 admin JS files.

To demonstrate adoptability, I'd also ship a "picocss" theme alongside it — extending the vanilla templates and just swapping in PicoCSS (~10KB classless CSS). If the vanilla HTML is truly semantic, PicoCSS should "just work" with minimal overrides.

BS4 stays untouched and remains the default. The original XEditableWidget and ajax/* endpoints are restored for BS4. The vanilla theme gets its own HTMXEditableWidget and new RESTful /inline/<pk>/<field>/ endpoints. No backward-incompatible changes.

There's a full design spec behind this. Happy to share if there's interest in discussing the details.

Thoughts?


Of course this is just an idea, the possible output might not be exactly like that.

@samuelhwilliams

samuelhwilliams commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

I'm proposing a "vanilla" theme — a dependency-free foundation that uses only semantic HTML, minimal custom CSS, and HTMX as the sole JS dependency. No jQuery, no Bootstrap, no Font Awesome.

I'm really in favour of this idea. It's been in the very back of my mind (in a very light way) that it would be nice if Flask-Admin had a very clear 'theming' API/interface that was well defined to support all of the actions needed for this vanilla API, and then use that. I think it would be great if themes for Flask-Admin could be published as separate packages and then just 'plugged in'. I suspect this requires quite a lot of up front thinking through and would be a big undertaking.

While working on a theme myself for some work projects I did have to mangle quite a lot of things and hit some flask-admin internals, so it's not a very clean process. Right now a lot of the functionality required for bootstrap is fairly closely integrated/coupled with flask-admin internals itself, so it'd be really great to detangle some of that.

I'd also strongly prefer that any new 'vanilla' theme we work towards is progessively enhanced, ie resilient to failures in JS (following best practice principles from eg GOV.UK: https://www.gov.uk/service-manual/technology/using-progressive-enhancement - I'm aware this is my specific context a lot of the time, but I think still a strong foundation). UX improvements should ideally be layered on top of that to provide a more full and modern experience.

@ElLorans

ElLorans commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

I agree with everything you are saying, but we already have closed PR and open PRs just to bring a new theme, and this suggestion increases the workload without bringing us further. My personal opinion is that we should push to get a bootstrap5/tabler whatever template, and then we can refactor from there.

@samuelhwilliams

Copy link
Copy Markdown
Collaborator

That approach is fine with me!

@samialfattani samialfattani mentioned this pull request Apr 1, 2026
9 tasks
@ElLorans

Copy link
Copy Markdown
Collaborator

I did a quick pass with copilot and it flags:

1. ajax_update() appears to use an unbound form

The new implementation creates form = self.list_form(), strips all fields except the edited one, validates it, and then passes that form to update_model():

if not self.update_model(form, record):
    ...

In the previous flow, updates were performed using a form bound to the existing object. Here the form does not appear to be instantiated with obj=record before update_model() is called. Depending on the assumptions inside update_model(), this could affect editable fields that rely on existing object state during processing.

Could we add tests covering partial updates of more complex field types to verify that behavior remains unchanged?

2. Editable fields no longer work for some valid primary keys

HTMXEditableWidget.__call__() now warns when the primary key contains characters outside [\w-] and notes that inline editing will not work for that record.

Many applications use string IDs, encoded composite keys, datastore-generated identifiers, or other key formats that may legitimately contain characters outside that set. This seems like a regression in functionality rather than just a warning condition.

Would it be possible to escape or encode identifiers instead of disabling inline editing for those records?

3. /ajax/update/ response format is now a breaking change

The changelog notes that /ajax/update/ now returns HTML instead of plain text. While documented, the endpoint URL itself remains unchanged.

Any downstream integrations that call this endpoint directly (rather than relying on Flask-Admin's built-in JS/UI) may continue to hit the same endpoint but receive a completely different response format.

Have you considered a compatibility layer or versioned endpoint for consumers that may depend on the previous behavior?

4. _original_widgets is stored as a class attribute

In create_editable_list_form():

class ListForm(...):
    _original_widgets: dict[str, t.Any] = {}

The comment indicates this should be per invocation, but it is still attached to the generated class rather than an instance. I may be missing something, but this looks like a potential source of state leakage if generated form classes are reused or cached.

Could we add tests covering multiple editable list forms to ensure widget restoration remains isolated?

Overall I like the direction of the HTMX-based implementation, but I think these areas would benefit from additional regression coverage before merge.

I will analyze the issues in the coming weeks and see if they are valid. If anybody has time before that, they are welcome.

@ElLorans

ElLorans commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

I have tried to address the issues in https://github.com/pallets-eco/flask-admin/tree/feat/htmx but I have not had much time so far.
I could not solve this problem: the values if cancelled and submitted "disappear"
image

hasansezertasan and others added 4 commits July 7, 2026 13:16
…nto hasansezertasan/feat/replace-xeditable-with-htmx

# Conflicts:
#	flask_admin/tests/mongoengine/test_basic.py
Adopt the cleaner submit flow from pallets-eco#2931 (form targets
`closest .editable-cell` and swaps `outerHTML`) and fix the popover
cancel/"disappearing value" bug reported against that approach.

The edit popover is a child of the `.editable-cell` trigger, whose
`hx-get` fires on click. Clicks inside the popover bubbled up and
re-opened the editor; the earlier workaround (`onclick=stopPropagation`)
stopped that but also killed the document-delegated cancel handler, so
the ✗ button did nothing and a cleared value looked lost. Instead, scope
the trigger declaratively with
`hx-trigger="click[!target.closest('.editable-popover')]"`, so inner
clicks never re-trigger the GET and cancel keeps working.

Also:
* Percent-encode the pk in the edit URL and stop using it as a CSS
  selector, so records with pks containing `&`, `#`, etc. are editable
  (previously inline editing was disabled for them with a warning).
* Drop the duplicate `id="editable-..."` on the `<td>` (the display
  `<span>` already carries it); `hx-target` is now unambiguous.
* Re-render validation-error popovers via an explicit
  `.editable-popover` selector instead of `body.firstChild`.

Co-authored-by: ElLorans <lorenzo.cerreta@gmail.com>
Pass the SQLAlchemy `db` object to `ModelView` instead of `db.session`
(the session form is deprecated and emitted a warning), and drop the
`.python-version` pin and empty `__init__.py`, matching pallets-eco#2931.

Co-authored-by: ElLorans <lorenzo.cerreta@gmail.com>
@hasansezertasan

Copy link
Copy Markdown
Member Author

Pushed fixes for the inline-editing issues raised above (the "cancel then submit → value disappears" report and the Copilot review).

Root cause of the disappearing value (reproduced in a real browser against #2931's feat/htmx): the edit popover is a child of the .editable-cell element, whose hx-get fires on click. Clicks inside the popover bubbled up and re-opened the editor, so onclick="event.stopPropagation()" was added to stop it — but that also blocked the document-delegated cancel handler, so the ✗ button did nothing. Clearing a value and clicking cancel left it stuck, and submitting then persisted the empty value (the blank Calories / 0.0 Price in the screenshot).

Fix: keep #2931's cleaner submit flow (hx-target="closest .editable-cell" + hx-swap="outerHTML") but drop the stopPropagation hack in favor of a declarative htmx trigger filter:

hx-trigger="click[!target.closest('.editable-popover')]"

Inside the popover, clicks no longer re-trigger the GET; outside, cancel works via normal bubbling. Verified: 0 GETs on inner clicks, 1 GET on a cell click.

This also clears two of Copilot's points:

  • PK characters: the pk is now percent-encoded in the edit URL and no longer used as a CSS selector, so records with pks containing &, #, etc. are editable (removed the warning that disabled them).
  • Duplicate id: dropped the id="editable-..." from the <td>; the display <span> already carries it, so hx-target is unambiguous.

Also ported the sqla_column_editable example from #2931 (passes db instead of the deprecated db.session).

Browser-verified: clear→cancel restores the value and closes; edit→submit updates and stays editable; server-side validation renders inline errors without wiping the cell. Editable test suites (sqla + peewee) pass.

Credit to @ElLorans — the outerHTML/closest approach comes from #2931, and those commits are co-authored accordingly.

…lation

Add the regression tests requested in the pallets-eco#2847 review:

* test_editable_partial_update_preserves_other_fields — proves a
  single-field ajax_update leaves the row's other columns (including
  other editable ones) untouched, so the request-bound (non obj=record)
  form can't wipe data.
* test_editable_widgets_isolated_between_views — proves two editable
  views don't share `_original_widgets` state: each restores its own
  input widget (datepicker vs. text) with no cross-view leakage.
@hasansezertasan

Copy link
Copy Markdown
Member Author

Follow-up on the Copilot review (#2847 (comment)) — status of the four points:

  1. Unbound form in ajax_update — verified this doesn't cause data loss and added a regression test: test_editable_partial_update_preserves_other_fields asserts a single-field edit leaves the row's other columns (including other editable ones) untouched, across two consecutive edits. The isolated form is fine because populate_obj only writes the one remaining field.
  2. PK characters — fixed: the pk is percent-encoded in the edit URL and no longer used as a CSS selector, so records with &, #, etc. in the pk are editable again (the warning that disabled them is gone).
  3. /ajax/update/ response format — left as an intentional breaking change, documented under Breaking changes in the changelog, consistent with shipping this as a major-version break. No compatibility shim.
  4. _original_widgets class attribute — verified safe and added test_editable_widgets_isolated_between_views: two editable views restore their own input widgets (datepicker vs. text) with no cross-view leakage. create_editable_list_form builds a fresh ListForm class per view and the dict is only ever read during restoration.

Both new tests run across all three SQLA provider variants. cc @ElLorans

- collapse a CustomModelView call per ruff-format (v0.4.7)
- narrow session.get() results with 'assert record is not None' for mypy
…etwork failure

The htmx:sendError handler for column_editable_list called
closeEditablePopover() and then alert(). On a transient network blip
this both froze the page and discarded the user's in-progress edit.

Keep the popover open and append a non-blocking .text-danger message so
the edit survives and the user can just retry.
…t=None

* test_editable_endpoints_require_can_edit — both GET /ajax/edit/ and
  POST /ajax/update/ must 404 when can_edit is False even with
  column_editable_list configured (the permission guard was untested).
* Document that get_list_value's context may be None when called outside
  template rendering (ajax_update recomputes a cell after an inline
  edit), so custom column_formatters must guard against it.
@ElLorans

Copy link
Copy Markdown
Collaborator

Thanks for the great work!
test_editable_widgets_isolated_between_views does not totally address the issue though: ListForm can still leak if used improperly. The test can prevent regressions from our sides, but still exposes a footgun for the end users.

Comment thread flask_admin/model/base.py
Return an edit form HTML fragment for a single editable cell.
Used by HTMX to swap the display state with an inline edit form.
"""
if not self.can_edit or not self.column_editable_list:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is if not self.can_edit a breaking change?
Do we currently allow editable columns through column_editable_list if can_edit is False?

Comment thread flask_admin/model/base.py
Comment on lines +2857 to +2858
widget = HTMXEditableWidget()
return widget(form[field_name], pk=pk, display_value=display_value)

@ElLorans ElLorans Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prevents custom subclassing to have any effect.
I think we should do something like

Suggested change
widget = HTMXEditableWidget()
return widget(form[field_name], pk=pk, display_value=display_value)
return form[field_name].widget(form[field_name], pk=pk, display_value=display_value)

Comment on lines +16 to +17
<option value="y" {{ 'selected' if form[field_name].data else '' }}>Yes</option>
<option value="" {{ 'selected' if not form[field_name].data else '' }}>No</option>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must wrap the Yes and No in a transl call

@ElLorans

Copy link
Copy Markdown
Collaborator

In order to reduce the breaking change and remove an external dependency (htmx), would it make sense to do something like

const resp = await fetch('./ajax/update/', { method: 'POST', body: formData });
if (resp.ok) {
  const cellHtml = await fetch(`./ajax/cell/?pk=${pk}&field=${field}`).then(r => r.text());
  cell.outerHTML = cellHtml;
} else {
  // old error contract: (message, 500)
}

This keeps ajax_update's response equal to pre-PR behavior but keeps your deduplicated formatting logic, at the cost of one extra small GET per successful edit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

Replacement for X-editable?

4 participants