feat: replace x-editable with HTMX for inline editing - #2847
feat: replace x-editable with HTMX for inline editing#2847hasansezertasan wants to merge 23 commits into
Conversation
7567a9f to
7dd7c63
Compare
|
I'd be more into showing a modal for update forms. |
9929fd3 to
7c29db5
Compare
|
Wow!! You say test passed but how many are testing the actual code changes? |
4d943b5 to
0d5b90e
Compare
|
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. |
Could you please provide more detailed information 🤓?
I taught the same. The breaking change to me seemded like "XEditableWidget", so I did a quick search for Is it possible to determine if it's a breaking change or not? |
|
Some LLM: I did a GitHub-wide search for The backwards compatibility alias is already in place: # flask_admin/model/widgets.py:113
XEditableWidget = HTMXEditableWidgetSo The one theoretical breaking case: someone who subclassed The alias can be removed with a deprecation warning in a future major version if desired. |
|
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 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? |
|
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. |
|
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. |
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.
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.
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. |
|
I think making this not a breaking change is not possible (correct me if I am wrong), but I agree the "breaking" is minor.
|
|
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?
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 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. |
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. |
|
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. |
|
That approach is fine with me! |
6752f16 to
98fad20
Compare
|
I did a quick pass with copilot and it flags:
I will analyze the issues in the coming weeks and see if they are valid. If anybody has time before that, they are welcome. |
|
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. |
…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>
|
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 Fix: keep #2931's cleaner submit flow ( 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:
Also ported the 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 |
…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.
|
Follow-up on the Copilot review (#2847 (comment)) — status of the four points:
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.
|
Thanks for the great work! |
| 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: |
There was a problem hiding this comment.
Is if not self.can_edit a breaking change?
Do we currently allow editable columns through column_editable_list if can_edit is False?
| widget = HTMXEditableWidget() | ||
| return widget(form[field_name], pk=pk, display_value=display_value) |
There was a problem hiding this comment.
This prevents custom subclassing to have any effect.
I think we should do something like
| 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) |
| <option value="y" {{ 'selected' if form[field_name].data else '' }}>Yes</option> | ||
| <option value="" {{ 'selected' if not form[field_name].data else '' }}>No</option> |
There was a problem hiding this comment.
We must wrap the Yes and No in a transl call
|
In order to reduce the breaking change and remove an external dependency (htmx), would it make sense to do something like This keeps |

Summary
column_editable_listAPI is unchanged — no user-facing breaking changesFixes #1615
Changes
XEditableWidget(125 lines, 12+ field type mappings)HTMXEditableWidget(30 lines, field-agnostic)afterSwaphandlersPOST /ajax/update/→ plain textGET /ajax/edit/(new) +POST /ajax/update/→ HTML fragmentsHow it works
GET /ajax/edit/?pk=X&field=Y<td>POST /ajax/update/Why HTMX
Test plan
test_ajax_edit_endpointcovers: valid field, non-editable field (404), non-existent record (404), nocolumn_editable_list(404)Manual testing
Run the "sqla_column_editable" example to test inline editing interactively.
What to test manually