Loading canaille/app/forms.py +10 −0 Original line number Diff line number Diff line Loading @@ -111,6 +111,11 @@ class HTMXFormMixin: if not fieldlist or not isinstance(fieldlist, wtforms.FieldList): abort(400) if fieldlist.render_kw and ( "readonly" in fieldlist.render_kw or "disabled" in fieldlist.render_kw ): abort(403) if request_is_htmx(): self.validate_field(fieldlist) Loading @@ -128,6 +133,11 @@ class HTMXFormMixin: if not fieldlist or not isinstance(fieldlist, wtforms.FieldList): abort(400) if fieldlist.render_kw and ( "readonly" in fieldlist.render_kw or "disabled" in fieldlist.render_kw ): abort(403) if request_is_htmx(): self.validate_field(fieldlist) Loading canaille/templates/macro/form.html +20 −15 Original line number Diff line number Diff line Loading @@ -11,12 +11,23 @@ add_button=false, del_button=false ) -%} {% set field_visible = field.type != 'HiddenField' and field.type !='CSRFTokenField' %} {% set disabled = kwargs.get("disabled") or (field.render_kw and "disabled" in field.render_kw) %} {% set readonly = kwargs.get("readonly") or (field.render_kw and "readonly" in field.render_kw) %} {% set required = "required" in field.flags %} {% set lock_indicator = readonly or disabled %} {% set corner_indicator = not noindicator and (indicator_icon or lock_indicator or required) %} {% set inline_validation = field.validators and field.type not in ("FileField", "MultipleFileField") %} {% if inline_validation %} {% set ignore_me = kwargs.update({"hx-post": ""}) %} {% set ignore_me = kwargs.update({"hx-indicator": "closest .input"}) %} {% endif %} {% if container and field_visible %} <div class="field {{ kwargs.pop('class_', '') }} {%- if field.errors %} error{% endif -%} {%- if field.render_kw and "disabled" in field.render_kw %} disabled{% endif -%}" {%- if disabled %} disabled{% endif -%}" {% if not display %}style="display: none"{% endif %} {% if field.validators and field.type not in ("FileField", "MultipleFileField") %}hx-target="this" hx-swap="outerHTML"{% endif %} {% if inline_validation %}hx-target="this" hx-swap="outerHTML"{% endif %} > {% endif %} Loading @@ -24,18 +35,11 @@ del_button=false {{ field.label() }} {% endif %} {% set lock_indicator = field.render_kw and ("readonly" in field.render_kw or "disabled" in field.render_kw) %} {% set required_indicator = "required" in field.flags %} {% set corner_indicator = not noindicator and (indicator_icon or lock_indicator or required_indicator) %} {% if field.validators and field.type not in ("FileField", "MultipleFileField") %} {% set ignore_me = kwargs.update({"hx-post": ""}) %} {% set ignore_me = kwargs.update({"hx-indicator": "closest .input"}) %} {% endif %} {% if field_visible %} <div class="ui {%- if corner_indicator %} corner labeled{% endif -%} {%- if icon or field.description %} left icon{% endif -%} {%- if add_button or del_button %} action{% endif -%} {%- if field_visible and not readonly and not disabled and (add_button or del_button) %} action{% endif -%} {%- if field.type not in ("BooleanField", "RadioField") %} input{% endif -%} "> {% endif %} Loading @@ -44,11 +48,11 @@ del_button=false {% if field.type not in ("SelectField", "SelectMultipleField") %} {{ field(**kwargs) }} {% elif field.type == "SelectMultipleField" and field.render_kw and "readonly" in field.render_kw %} {% elif field.type == "SelectMultipleField" and readonly %} {{ field(class_="ui fluid dropdown multiple read-only", **kwargs) }} {% elif field.type == "SelectMultipleField" %} {{ field(class_="ui fluid dropdown multiple", **kwargs) }} {% elif field.type == "SelectField" and field.render_kw and "readonly" in field.render_kw %} {% elif field.type == "SelectField" and readonly %} {{ field(class_="ui fluid dropdown read-only", **kwargs) }} {% elif field.type == "SelectField" %} {{ field(class_="ui fluid dropdown", **kwargs) }} Loading @@ -63,7 +67,7 @@ del_button=false <div class="ui corner label" title="{{ _("This field is not editable") }}"> <i class="lock icon"></i> </div> {% elif required_indicator %} {% elif required %} <div class="ui corner label" title="{{ _("This field is required") }}"> <i class="asterisk icon"></i> </div> Loading @@ -71,7 +75,7 @@ del_button=false {% endif %} {% if field_visible %} {% if del_button %} {% if not readonly and not disabled and del_button %} <button class="ui teal icon button" title="{{ _("Remove this field") }}" Loading @@ -86,7 +90,7 @@ del_button=false <i class="minus icon"></i> </button> {% endif %} {% if add_button %} {% if not readonly and not disabled and add_button %} <button class="ui teal icon button" title="{{ _("Add another field") }}" Loading Loading @@ -147,6 +151,7 @@ del_button=false {# Strangely enough, translations are not rendered when using field.label() #} {{ field[0].label() }} {% for subfield in field %} {% set ignore_me = kwargs.update(**field.render_kw or {}) %} {{ render_field( subfield, parent_list=field, Loading tests/app/test_forms.py +36 −0 Original line number Diff line number Diff line Loading @@ -405,6 +405,42 @@ def test_fieldlist_remove_field_htmx(testclient, logged_admin): assert 'name="redirect_uris-1' not in response.text def test_fieldlist_add_readonly(testclient, logged_user, configuration): configuration["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers") configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers") res = testclient.get("/profile/user") assert res.form["phone_numbers-0"].attrs["readonly"] assert "phone_numbers-1" not in res.form.fields data = { "csrf_token": res.form["csrf_token"].value, "family_name": res.form["family_name"].value, "phone_numbers-0": res.form["phone_numbers-0"].value, "fieldlist_add": "phone_numbers-0", } testclient.post("/profile/user", data, status=403) def test_fieldlist_remove_readonly(testclient, logged_user, configuration): configuration["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers") configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers") logged_user.phone_numbers = ["555-555-000", "555-555-111"] logged_user.save() res = testclient.get("/profile/user") assert res.form["phone_numbers-0"].attrs["readonly"] assert res.form["phone_numbers-1"].attrs["readonly"] data = { "csrf_token": res.form["csrf_token"].value, "family_name": res.form["family_name"].value, "phone_numbers-0": res.form["phone_numbers-0"].value, "fieldlist_remove": "phone_numbers-1", } testclient.post("/profile/user", data, status=403) def test_fieldlist_inline_validation(testclient, logged_admin): res = testclient.get("/admin/client/add") data = { Loading Loading
canaille/app/forms.py +10 −0 Original line number Diff line number Diff line Loading @@ -111,6 +111,11 @@ class HTMXFormMixin: if not fieldlist or not isinstance(fieldlist, wtforms.FieldList): abort(400) if fieldlist.render_kw and ( "readonly" in fieldlist.render_kw or "disabled" in fieldlist.render_kw ): abort(403) if request_is_htmx(): self.validate_field(fieldlist) Loading @@ -128,6 +133,11 @@ class HTMXFormMixin: if not fieldlist or not isinstance(fieldlist, wtforms.FieldList): abort(400) if fieldlist.render_kw and ( "readonly" in fieldlist.render_kw or "disabled" in fieldlist.render_kw ): abort(403) if request_is_htmx(): self.validate_field(fieldlist) Loading
canaille/templates/macro/form.html +20 −15 Original line number Diff line number Diff line Loading @@ -11,12 +11,23 @@ add_button=false, del_button=false ) -%} {% set field_visible = field.type != 'HiddenField' and field.type !='CSRFTokenField' %} {% set disabled = kwargs.get("disabled") or (field.render_kw and "disabled" in field.render_kw) %} {% set readonly = kwargs.get("readonly") or (field.render_kw and "readonly" in field.render_kw) %} {% set required = "required" in field.flags %} {% set lock_indicator = readonly or disabled %} {% set corner_indicator = not noindicator and (indicator_icon or lock_indicator or required) %} {% set inline_validation = field.validators and field.type not in ("FileField", "MultipleFileField") %} {% if inline_validation %} {% set ignore_me = kwargs.update({"hx-post": ""}) %} {% set ignore_me = kwargs.update({"hx-indicator": "closest .input"}) %} {% endif %} {% if container and field_visible %} <div class="field {{ kwargs.pop('class_', '') }} {%- if field.errors %} error{% endif -%} {%- if field.render_kw and "disabled" in field.render_kw %} disabled{% endif -%}" {%- if disabled %} disabled{% endif -%}" {% if not display %}style="display: none"{% endif %} {% if field.validators and field.type not in ("FileField", "MultipleFileField") %}hx-target="this" hx-swap="outerHTML"{% endif %} {% if inline_validation %}hx-target="this" hx-swap="outerHTML"{% endif %} > {% endif %} Loading @@ -24,18 +35,11 @@ del_button=false {{ field.label() }} {% endif %} {% set lock_indicator = field.render_kw and ("readonly" in field.render_kw or "disabled" in field.render_kw) %} {% set required_indicator = "required" in field.flags %} {% set corner_indicator = not noindicator and (indicator_icon or lock_indicator or required_indicator) %} {% if field.validators and field.type not in ("FileField", "MultipleFileField") %} {% set ignore_me = kwargs.update({"hx-post": ""}) %} {% set ignore_me = kwargs.update({"hx-indicator": "closest .input"}) %} {% endif %} {% if field_visible %} <div class="ui {%- if corner_indicator %} corner labeled{% endif -%} {%- if icon or field.description %} left icon{% endif -%} {%- if add_button or del_button %} action{% endif -%} {%- if field_visible and not readonly and not disabled and (add_button or del_button) %} action{% endif -%} {%- if field.type not in ("BooleanField", "RadioField") %} input{% endif -%} "> {% endif %} Loading @@ -44,11 +48,11 @@ del_button=false {% if field.type not in ("SelectField", "SelectMultipleField") %} {{ field(**kwargs) }} {% elif field.type == "SelectMultipleField" and field.render_kw and "readonly" in field.render_kw %} {% elif field.type == "SelectMultipleField" and readonly %} {{ field(class_="ui fluid dropdown multiple read-only", **kwargs) }} {% elif field.type == "SelectMultipleField" %} {{ field(class_="ui fluid dropdown multiple", **kwargs) }} {% elif field.type == "SelectField" and field.render_kw and "readonly" in field.render_kw %} {% elif field.type == "SelectField" and readonly %} {{ field(class_="ui fluid dropdown read-only", **kwargs) }} {% elif field.type == "SelectField" %} {{ field(class_="ui fluid dropdown", **kwargs) }} Loading @@ -63,7 +67,7 @@ del_button=false <div class="ui corner label" title="{{ _("This field is not editable") }}"> <i class="lock icon"></i> </div> {% elif required_indicator %} {% elif required %} <div class="ui corner label" title="{{ _("This field is required") }}"> <i class="asterisk icon"></i> </div> Loading @@ -71,7 +75,7 @@ del_button=false {% endif %} {% if field_visible %} {% if del_button %} {% if not readonly and not disabled and del_button %} <button class="ui teal icon button" title="{{ _("Remove this field") }}" Loading @@ -86,7 +90,7 @@ del_button=false <i class="minus icon"></i> </button> {% endif %} {% if add_button %} {% if not readonly and not disabled and add_button %} <button class="ui teal icon button" title="{{ _("Add another field") }}" Loading Loading @@ -147,6 +151,7 @@ del_button=false {# Strangely enough, translations are not rendered when using field.label() #} {{ field[0].label() }} {% for subfield in field %} {% set ignore_me = kwargs.update(**field.render_kw or {}) %} {{ render_field( subfield, parent_list=field, Loading
tests/app/test_forms.py +36 −0 Original line number Diff line number Diff line Loading @@ -405,6 +405,42 @@ def test_fieldlist_remove_field_htmx(testclient, logged_admin): assert 'name="redirect_uris-1' not in response.text def test_fieldlist_add_readonly(testclient, logged_user, configuration): configuration["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers") configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers") res = testclient.get("/profile/user") assert res.form["phone_numbers-0"].attrs["readonly"] assert "phone_numbers-1" not in res.form.fields data = { "csrf_token": res.form["csrf_token"].value, "family_name": res.form["family_name"].value, "phone_numbers-0": res.form["phone_numbers-0"].value, "fieldlist_add": "phone_numbers-0", } testclient.post("/profile/user", data, status=403) def test_fieldlist_remove_readonly(testclient, logged_user, configuration): configuration["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers") configuration["ACL"]["DEFAULT"]["READ"].append("phone_numbers") logged_user.phone_numbers = ["555-555-000", "555-555-111"] logged_user.save() res = testclient.get("/profile/user") assert res.form["phone_numbers-0"].attrs["readonly"] assert res.form["phone_numbers-1"].attrs["readonly"] data = { "csrf_token": res.form["csrf_token"].value, "family_name": res.form["family_name"].value, "phone_numbers-0": res.form["phone_numbers-0"].value, "fieldlist_remove": "phone_numbers-1", } testclient.post("/profile/user", data, status=403) def test_fieldlist_inline_validation(testclient, logged_admin): res = testclient.get("/admin/client/add") data = { Loading