').append(this.$clear)))},value2html:function(a,c){var d=a?this.dpg.formatDate(a,this.parsedViewFormat,this.options.datepicker.language):"";b.superclass.value2html.call(this,d,c)},html2value:function(a){return this.parseDate(a,this.parsedViewFormat)},value2str:function(a){return a?this.dpg.formatDate(a,this.parsedFormat,this.options.datepicker.language):""},str2value:function(a){return this.parseDate(a,this.parsedFormat)},value2submit:function(a){return this.value2str(a)},value2input:function(a){this.$input.bdatepicker("update",a)},input2value:function(){return this.$input.data("datepicker").date},activate:function(){},clear:function(){this.$input.data("datepicker").date=null,this.$input.find(".active").removeClass("active"),this.options.showbuttons||this.$input.closest("form").submit()},autosubmit:function(){this.$input.on("mouseup",".day",function(b){if(!a(b.currentTarget).is(".old")&&!a(b.currentTarget).is(".new")){var c=a(this).closest("form");setTimeout(function(){c.submit()},200)}})},parseDate:function(a,b){var c,d=null;return a&&(d=this.dpg.parseDate(a,b,this.options.datepicker.language),"string"==typeof a&&(c=this.dpg.formatDate(d,b,this.options.datepicker.language),a!==c&&(d=null))),d}}),b.defaults=a.extend({},a.fn.editabletypes.abstractinput.defaults,{tpl:'
',inputclass:null,format:"yyyy-mm-dd",viewformat:null,datepicker:{weekStart:0,startView:0,minViewMode:0,autoclose:!1},clear:"× clear"}),a.fn.editabletypes.date=b}(window.jQuery),function(a){"use strict";var b=function(a){this.init("datefield",a,b.defaults),this.initPicker(a,b.defaults)};a.fn.editableutils.inherit(b,a.fn.editabletypes.date),a.extend(b.prototype,{render:function(){this.$input=this.$tpl.find("input"),this.setClass(),this.setAttr("placeholder"),this.$input.bdatepicker(this.options.datepicker),this.$input.off("focus keydown"),this.$input.keyup(a.proxy(function(){this.$tpl.removeData("date"),this.$tpl.bdatepicker("update")},this))},value2input:function(a){this.$input.val(a?this.dpg.formatDate(a,this.parsedViewFormat,this.options.datepicker.language):""),this.$tpl.bdatepicker("update")},input2value:function(){return this.html2value(this.$input.val())},activate:function(){a.fn.editabletypes.text.prototype.activate.call(this)},autosubmit:function(){}}),b.defaults=a.extend({},a.fn.editabletypes.date.defaults,{tpl:'
',inputclass:"input-small",datepicker:{weekStart:0,startView:0,minViewMode:0,autoclose:!0}}),a.fn.editabletypes.datefield=b}(window.jQuery),function(a){"use strict";var b=function(a){this.init("datetime",a,b.defaults),this.initPicker(a,b.defaults)};a.fn.editableutils.inherit(b,a.fn.editabletypes.abstractinput),a.extend(b.prototype,{initPicker:function(b,c){this.options.viewformat||(this.options.viewformat=this.options.format),b.datetimepicker=a.fn.editableutils.tryParseJson(b.datetimepicker,!0),this.options.datetimepicker=a.extend({},c.datetimepicker,b.datetimepicker,{format:this.options.viewformat}),this.options.datetimepicker.language=this.options.datetimepicker.language||"en",this.dpg=a.fn.datetimepicker.DPGlobal,this.parsedFormat=this.dpg.parseFormat(this.options.format,this.options.formatType),this.parsedViewFormat=this.dpg.parseFormat(this.options.viewformat,this.options.formatType)},render:function(){this.$input.datetimepicker(this.options.datetimepicker),this.$input.on("changeMode",function(b){var c=a(this).closest("form").parent();setTimeout(function(){c.triggerHandler("resize")},0)}),this.options.clear&&(this.$clear=a('
').html(this.options.clear).click(a.proxy(function(a){a.preventDefault(),a.stopPropagation(),this.clear()},this)),this.$tpl.parent().append(a('
').append(this.$clear)))},value2html:function(a,c){var d=a?this.dpg.formatDate(this.toUTC(a),this.parsedViewFormat,this.options.datetimepicker.language,this.options.formatType):"";return c?void b.superclass.value2html.call(this,d,c):d},html2value:function(a){var b=this.parseDate(a,this.parsedViewFormat);return b?this.fromUTC(b):null},value2str:function(a){return a?this.dpg.formatDate(this.toUTC(a),this.parsedFormat,this.options.datetimepicker.language,this.options.formatType):""},str2value:function(a){var b=this.parseDate(a,this.parsedFormat);return b?this.fromUTC(b):null},value2submit:function(a){return this.value2str(a)},value2input:function(a){a&&this.$input.data("datetimepicker").setDate(a)},input2value:function(){var a=this.$input.data("datetimepicker");return a.date?a.getDate():null},activate:function(){},clear:function(){this.$input.data("datetimepicker").date=null,this.$input.find(".active").removeClass("active"),this.options.showbuttons||this.$input.closest("form").submit()},autosubmit:function(){this.$input.on("mouseup",".minute",function(b){var c=a(this).closest("form");setTimeout(function(){c.submit()},200)})},toUTC:function(a){return a?new Date(a.valueOf()-6e4*a.getTimezoneOffset()):a},fromUTC:function(a){return a?new Date(a.valueOf()+6e4*a.getTimezoneOffset()):a},parseDate:function(a,b){var c,d=null;return a&&(d=this.dpg.parseDate(a,b,this.options.datetimepicker.language,this.options.formatType),"string"==typeof a&&(c=this.dpg.formatDate(d,b,this.options.datetimepicker.language,this.options.formatType),a!==c&&(d=null))),d}}),b.defaults=a.extend({},a.fn.editabletypes.abstractinput.defaults,{tpl:'
',inputclass:null,format:"yyyy-mm-dd hh:ii",formatType:"standard",viewformat:null,datetimepicker:{todayHighlight:!1,autoclose:!1},clear:"× clear"}),a.fn.editabletypes.datetime=b}(window.jQuery),function(a){"use strict";var b=function(a){this.init("datetimefield",a,b.defaults),this.initPicker(a,b.defaults)};a.fn.editableutils.inherit(b,a.fn.editabletypes.datetime),a.extend(b.prototype,{render:function(){this.$input=this.$tpl.find("input"),this.setClass(),this.setAttr("placeholder"),this.$tpl.datetimepicker(this.options.datetimepicker),this.$input.off("focus keydown"),this.$input.keyup(a.proxy(function(){this.$tpl.removeData("date"),this.$tpl.datetimepicker("update")},this))},value2input:function(a){this.$input.val(this.value2html(a)),this.$tpl.datetimepicker("update")},input2value:function(){return this.html2value(this.$input.val())},activate:function(){a.fn.editabletypes.text.prototype.activate.call(this)},autosubmit:function(){}}),b.defaults=a.extend({},a.fn.editabletypes.datetime.defaults,{tpl:'
',inputclass:"input-medium",datetimepicker:{todayHighlight:!1,autoclose:!0}}),a.fn.editabletypes.datetimefield=b}(window.jQuery);
diff --git a/flask_admin/templates/bootstrap4/admin/lib.html b/flask_admin/templates/bootstrap4/admin/lib.html
index a99a78ca7e..22e1107670 100644
--- a/flask_admin/templates/bootstrap4/admin/lib.html
+++ b/flask_admin/templates/bootstrap4/admin/lib.html
@@ -251,9 +251,48 @@
{{ text }}
{% endif %}
- {% if editable_columns is defined and editable_columns %}
-
- {% endif %}
+
{% endmacro %}
{% macro form_js() %}
@@ -280,7 +319,7 @@
{{ text }}
{% endif %}
{% if editable_columns is defined and editable_columns %}
-
+
{% endif %}
{% endmacro %}
diff --git a/flask_admin/templates/bootstrap4/admin/model/editable_cell_edit.html b/flask_admin/templates/bootstrap4/admin/model/editable_cell_edit.html
new file mode 100644
index 0000000000..2a17282584
--- /dev/null
+++ b/flask_admin/templates/bootstrap4/admin/model/editable_cell_edit.html
@@ -0,0 +1,34 @@
+
+
+ {% if errors and field_name in errors %}
+
{{ errors[field_name] | join(', ') }}
+ {% endif %}
+
+
diff --git a/flask_admin/templates/bootstrap4/admin/model/list.html b/flask_admin/templates/bootstrap4/admin/model/list.html
index dcfb21907c..b5e526e831 100644
--- a/flask_admin/templates/bootstrap4/admin/model/list.html
+++ b/flask_admin/templates/bootstrap4/admin/model/list.html
@@ -132,20 +132,16 @@
{% endblock %}
{% for c, name in list_columns %}
-
{% if admin_view.is_editable(c) %}
+ |
{% set form = list_forms[get_pk_value(row)] %}
- {% if form.csrf_token is defined and form.csrf_token %}
- {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }}
- {% elif csrf_token is defined and csrf_token %}
- {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=csrf_token()) }}
- {% else %}
{{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }}
- {% endif %}
+ |
{% else %}
+
{{ get_value(row, c) }}
- {% endif %}
|
+ {% endif %}
{% endfor %}
{% endblock %}
diff --git a/flask_admin/tests/mongoengine/test_basic.py b/flask_admin/tests/mongoengine/test_basic.py
index 4261ff5ae2..96a47ed3cb 100644
--- a/flask_admin/tests/mongoengine/test_basic.py
+++ b/flask_admin/tests/mongoengine/test_basic.py
@@ -2,6 +2,7 @@
from flask import Flask
from mongoengine import Document
+from mongoengine import ReferenceField
from mongoengine import StringField
from mongoengine.connection import get_db
from wtforms import fields
@@ -155,3 +156,103 @@ class TestModel(Document): # type: ignore[misc]
assert loader.name == "test_field"
assert loader.options == {"fields": ["name"]}
+
+
+def test_column_editable_list(app: Flask, db: t.Any, admin: Admin) -> None:
+ class EditableModel(Document): # type: ignore[misc]
+ meta = {"collection": "editable_test"}
+ test1 = StringField(max_length=20)
+ test2 = StringField()
+
+ class RelatedModel(Document): # type: ignore[misc]
+ meta = {"collection": "editable_related_test"}
+ name = StringField()
+ ref = ReferenceField(EditableModel)
+
+ def __str__(self) -> str:
+ return self.name or ""
+
+ # Drop existing data
+ mongo_db = get_db()
+ for name in ("editable_test", "editable_related_test"):
+ mongo_db.drop_collection(name)
+
+ class EditableModelView(ModelView):
+ column_editable_list = ["test1"]
+
+ class RelatedModelView(ModelView):
+ column_editable_list = ["ref"]
+
+ view = EditableModelView(EditableModel, "EditableModel", endpoint="editable_model")
+ admin.add_view(view)
+
+ view2 = RelatedModelView(RelatedModel, "RelatedModel", endpoint="editable_related")
+ admin.add_view(view2)
+
+ # Seed data
+ obj1 = EditableModel(test1="val1", test2="val2").save()
+ obj2 = EditableModel(test1="val2", test2="val3").save()
+ related_obj = RelatedModel(name="related1", ref=obj1).save()
+
+ client = app.test_client()
+
+ # Test in-line edit field rendering
+ rv = client.get("/admin/editable_model/")
+ data = rv.data.decode("utf-8")
+ assert "hx-get=" in data
+ assert 'class="editable-cell"' in data
+
+ # Test basic in-line edit functionality
+ rv = client.post(
+ "/admin/editable_model/ajax/update/",
+ data={
+ "list_form_pk": str(obj1.pk),
+ "test1": "change-success-1",
+ },
+ )
+ data = rv.data.decode("utf-8")
+ assert "change-success-1" in data
+ assert 'class="editable-cell"' in data
+
+ # Ensure the value has changed
+ rv = client.get("/admin/editable_model/")
+ data = rv.data.decode("utf-8")
+ assert "change-success-1" in data
+
+ # Test editing column not in column_editable_list
+ rv = client.post(
+ "/admin/editable_model/ajax/update/",
+ data={
+ "list_form_pk": str(obj1.pk),
+ "test2": "problematic-input",
+ },
+ )
+ assert rv.status_code == 404
+
+ # Test ajax_edit endpoint
+ rv = client.get(f"/admin/editable_model/ajax/edit/?pk={obj1.pk}&field=test1")
+ data = rv.data.decode("utf-8")
+ assert rv.status_code == 200
+ assert 'hx-post="./ajax/update/"' in data
+ assert 'name="test1"' in data
+
+ # Test ajax_edit for non-editable field
+ rv = client.get(f"/admin/editable_model/ajax/edit/?pk={obj1.pk}&field=test2")
+ assert rv.status_code == 404
+
+ # Test relation editing
+ rv = client.post(
+ "/admin/editable_related/ajax/update/",
+ data={
+ "list_form_pk": str(related_obj.pk),
+ "ref": str(obj2.pk),
+ },
+ )
+ data = rv.data.decode("utf-8")
+ assert 'class="editable-cell"' in data
+
+ # Verify the relation actually changed in the database
+ related_obj.reload()
+ assert related_obj.ref.pk == obj2.pk
+ # Also verify the related object's name appears in the response
+ assert str(obj2) in data
diff --git a/flask_admin/tests/peeweemodel/test_basic.py b/flask_admin/tests/peeweemodel/test_basic.py
index 6d2d387618..66678b646f 100644
--- a/flask_admin/tests/peeweemodel/test_basic.py
+++ b/flask_admin/tests/peeweemodel/test_basic.py
@@ -216,7 +216,8 @@ def test_column_editable_list(
# Test in-line edit field rendering
rv = client.get("/admin/model1/")
data = rv.data.decode("utf-8")
- assert 'data-role="x-editable"' in data
+ assert "hx-get=" in data
+ assert 'class="editable-cell"' in data
# Form - Test basic in-line edit functionality
rv = client.post(
@@ -227,7 +228,8 @@ def test_column_editable_list(
},
)
data = rv.data.decode("utf-8")
- assert "Record was successfully saved." == data
+ assert "change-success-1" in data
+ assert 'class="editable-cell"' in data
# ensure the value has changed
rv = client.get("/admin/model1/")
@@ -247,6 +249,7 @@ def test_column_editable_list(
)
data = rv.data.decode("utf-8")
assert rv.status_code == 500
+ assert 'hx-post="./ajax/update/"' in data # edit form re-rendered with errors
# Test invalid primary key
rv = client.post(
@@ -278,7 +281,7 @@ def test_column_editable_list(
},
)
data = rv.data.decode("utf-8")
- assert "Record was successfully saved." == data
+ assert 'class="editable-cell"' in data
# confirm the value has changed
rv = client.get("/admin/model2/")
diff --git a/flask_admin/tests/sqla/test_basic.py b/flask_admin/tests/sqla/test_basic.py
index f11cddb886..91ad9f5123 100644
--- a/flask_admin/tests/sqla/test_basic.py
+++ b/flask_admin/tests/sqla/test_basic.py
@@ -733,7 +733,7 @@ def test_column_editable_list(
# Test in-line edit field rendering
rv = client.get("/admin/model1/")
data = rv.data.decode("utf-8")
- assert 'data-role="x-editable"' in data
+ assert "hx-get=" in data
# Form - Test basic in-line edit functionality
rv = client.post(
@@ -744,7 +744,8 @@ def test_column_editable_list(
},
)
data = rv.data.decode("utf-8")
- assert "Record was successfully saved." == data
+ assert "change-success-1" in data
+ assert 'class="editable-cell"' in data
# ensure the value has changed
rv = client.get("/admin/model1/")
@@ -760,6 +761,8 @@ def test_column_editable_list(
},
)
assert rv.status_code == 500
+ data = rv.data.decode("utf-8")
+ assert 'hx-post="./ajax/update/"' in data # edit form re-rendered with errors
# Test invalid primary key
rv = client.post(
@@ -780,6 +783,7 @@ def test_column_editable_list(
"test2": "problematic-input",
},
)
+ assert rv.status_code == 404
data = rv.data.decode("utf-8")
assert "problematic-input" not in data
@@ -791,13 +795,239 @@ def test_column_editable_list(
},
)
data = rv.data.decode("utf-8")
- assert "Record was successfully saved." == data
+ assert "test1_val_3" in data
+ assert 'class="editable-cell"' in data
# confirm the value has changed
rv = client.get("/admin/model2/")
data = rv.data.decode("utf-8")
assert "test1_val_3" in data
+ # Test validation error
+ rv = client.post(
+ "/admin/model1/ajax/update/",
+ data={
+ "list_form_pk": "1",
+ "enum_field": "problematic-input",
+ },
+ )
+ assert rv.status_code == 500
+ data = rv.data.decode("utf-8")
+ assert 'hx-post="./ajax/update/"' in data # edit form re-rendered with errors
+ assert (
+ "Not a valid choice" in data
+ ) # <-- Verify WTForms validation err msg is displayed
+
+ # Test invalid primary key
+ rv = client.post(
+ "/admin/model1/ajax/update/",
+ data={
+ "list_form_pk": "1000",
+ "test1": "problematic-input",
+ },
+ )
+ data = rv.data.decode("utf-8")
+ assert rv.status_code == 500
+ assert (
+ "Record not found." in data
+ ) # <-- Verify the HTMX error template msg is displayed
+
+
+def test_ajax_edit_endpoint(
+ app: Flask,
+ sqla_db_ext: T_ANY_SQLA_PROVIDER,
+ admin: Admin,
+ session_or_db: T_LITERAL_SESSION_OR_DB,
+) -> None:
+ """Tests the GET /ajax/edit/ endpoint returns an edit form fragment."""
+ with app.app_context():
+ Model1, Model2 = create_models(sqla_db_ext)
+
+ param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
+ view = CustomModelView(
+ Model1, param, column_editable_list=["test1", "enum_field"]
+ )
+ admin.add_view(view)
+
+ # Add view2 before any requests so Flask can register the blueprint
+ view2 = CustomModelView(Model2, param)
+ admin.add_view(view2)
+
+ fill_db(sqla_db_ext, Model1, Model2)
+
+ client = app.test_client()
+
+ # Test fetching edit form for a valid field
+ rv = client.get("/admin/model1/ajax/edit/?pk=1&field=test1")
+ data = rv.data.decode("utf-8")
+ assert rv.status_code == 200
+ assert 'hx-post="./ajax/update/"' in data
+ assert 'name="list_form_pk"' in data
+ assert 'name="test1"' in data
+
+ # Test fetching edit form for non-editable field returns 404
+ rv = client.get("/admin/model1/ajax/edit/?pk=1&field=test2")
+ assert rv.status_code == 404
+
+ # Test fetching edit form for non-existent record returns 404
+ rv = client.get("/admin/model1/ajax/edit/?pk=9999&field=test1")
+ assert rv.status_code == 404
+
+ # Test endpoint without column_editable_list returns 404
+ rv = client.get("/admin/model2/ajax/edit/?pk=1&field=string_field")
+ assert rv.status_code == 404
+
+
+def test_editable_list_field_types(
+ app: Flask,
+ sqla_db_ext: T_ANY_SQLA_PROVIDER,
+ admin: Admin,
+ session_or_db: T_LITERAL_SESSION_OR_DB,
+) -> None:
+ """Tests inline editing for various field types."""
+ with app.app_context():
+ Model1, Model2 = create_models(sqla_db_ext)
+
+ param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
+ view = CustomModelView(
+ Model1,
+ param,
+ column_editable_list=[
+ "test3",
+ "bool_field",
+ "date_field",
+ "time_field",
+ "datetime_field",
+ ],
+ )
+ admin.add_view(view)
+
+ view2 = CustomModelView(
+ Model2,
+ param,
+ column_editable_list=[
+ "int_field",
+ "float_field",
+ ],
+ )
+ admin.add_view(view2)
+
+ fill_db(sqla_db_ext, Model1, Model2)
+
+ client = app.test_client()
+
+ # -- TextAreaField: edit and save --
+ rv = client.post(
+ "/admin/model1/ajax/update/",
+ data={
+ "list_form_pk": "1",
+ "test3": "updated text area content",
+ },
+ )
+ data = rv.data.decode("utf-8")
+ assert rv.status_code == 200
+ assert "updated text area content" in data
+ assert 'class="editable-cell"' in data
+
+ # -- TextAreaField: edit form renders textarea --
+ rv = client.get("/admin/model1/ajax/edit/?pk=1&field=test3")
+ data = rv.data.decode("utf-8")
+ assert rv.status_code == 200
+ assert "