From c0978419f25b5640ffe05d10f59a5e1edbece34d Mon Sep 17 00:00:00 2001 From: catborise Date: Sat, 31 Oct 2026 12:39:32 +0300 Subject: [PATCH] feat(appsettings): the settings page with a group index, two columns and switches The Settings page was one tall column of cards, a label column beside every field and True/False chosen from a select. Now an index of the groups stays beside the cards on a wide screen and wraps above them on a phone; each group lays its fields out in two columns with the label above; a True/False setting is a switch; each Save names its group. A switch posts nothing when off, so a hidden False of the same name comes first and a checked switch's True, posted last, wins: the view and its was-KEY check stay as they are. --- appsettings/templates/appsettings.html | 155 +++++++++++++++---------- appsettings/tests/test_appsettings.py | 26 ++++- 2 files changed, 115 insertions(+), 66 deletions(-) diff --git a/appsettings/templates/appsettings.html b/appsettings/templates/appsettings.html index 7ec441cb..880ddcf0 100644 --- a/appsettings/templates/appsettings.html +++ b/appsettings/templates/appsettings.html @@ -5,72 +5,101 @@ {% block page_heading %}{% trans "Edit Settings" %}{% endblock page_heading %} -{% block content %} -
-
-

{% trans "Appearance" %}

-
{% csrf_token %} - -
- -
{% trans "Yours only, in this browser" %}
-
-
-
-
{% csrf_token %} - -
- -
{% trans "For every user" %}
-
-
-
-
-
+{% block style %} + +{% endblock style %} -{% for title, settings in groups %} -
-
-
{% csrf_token %} -

{% trans title %}

- {% for setting in settings %} -
- -
- {% if setting.choices %} - + {% get_current_language as LANGUAGE_CODE %} + {% get_available_languages as LANGUAGES %} + {% get_language_info_list for LANGUAGES as languages %} + {% for language in languages %} + {% endfor %} - {% elif setting.bounds %} - - {% else %} - - {% endif %} - -
{% trans setting.description %}
+
{% trans "Yours only, in this browser" %}
+ + +
{% csrf_token %} + + +
{% trans "For every user" %}
+ +
+
+
+ + {% for title, settings in groups %} + {% trans title as group_title %} +
+

{{ group_title }}

+
{% csrf_token %} +
+ {% for setting in settings %} +
+ {% if setting.choices == "True,False" %} + {# an unchecked box posts nothing: the hidden False goes first, a checked box's True wins #} + +
+ + +
+ {% else %} + + {% if setting.choices %} + + {% elif setting.bounds %} + + {% else %} + + {% endif %} + {% endif %} + +
{% trans setting.description %}
+
+ {% endfor %}
- - {% endfor %} -
- -
-
+
+ +
+ +
+ {% endfor %} - -{% endfor %} + {% endblock %} diff --git a/appsettings/tests/test_appsettings.py b/appsettings/tests/test_appsettings.py index 4bb41ebb..9ce00f8b 100644 --- a/appsettings/tests/test_appsettings.py +++ b/appsettings/tests/test_appsettings.py @@ -90,12 +90,17 @@ class AppSettingsTestCase(TestCase): def test_settings_are_grouped_with_their_descriptions_shown(self): page = self.page() - titles = [h.text_content().strip() for h in page.xpath("//form//h2")] - self.assertEqual(titles[:3], ["New VM defaults", "Disks", "Clones"]) + titles = [h.text_content().strip() for h in page.xpath("//section/div[@class='card-header']/h2")] + self.assertEqual(titles[:4], ["Appearance", "New VM defaults", "Disks", "Clones"]) + # the index jumps to each group + links = page.xpath("//nav[@aria-label='Settings groups']//a/@href") + self.assertEqual(links[:3], ["#appearance", "#new-vm-defaults", "#disks"]) + self.assertEqual([page.get_element_by_id(link[1:]).tag for link in links], ["section"] * len(links)) # every setting is in one group, with a label tied to its field and its description shown keys = page.xpath("//form//*[self::input or self::select][@name and @name!='csrfmiddlewaretoken' and not(starts-with(@name, 'was-'))]/@name") names = set(AppSettings.objects.exclude(key="BOOTSTRAP_THEME").values_list("key", flat=True)) - self.assertEqual(sorted(k for k in keys if k not in ("BOOTSTRAP_THEME", "language")), sorted(names)) + # a switch has its hidden False under the same name + self.assertEqual(sorted({k for k in keys if k not in ("BOOTSTRAP_THEME", "language")}), sorted(names)) (field,) = page.xpath("//*[@name='LOGS_PER_PAGE']") self.assertEqual((field.get("type"), field.get("min"), field.get("max")), ("number", "1", "1000")) self.assertEqual(page.xpath(f"//label[@for='{field.get('id')}']/text()"), ["Logs per Page"]) @@ -120,6 +125,21 @@ class AppSettingsTestCase(TestCase): self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], ["Saved: Console Clip Viewport, Console Scale"]) + def test_a_true_or_false_setting_is_a_switch(self): + AppSettings.objects.filter(key="CONSOLE_SCALE").update(value="True") + page = self.page() + # in the form's order: the hidden False, the switch, the value the page showed + fields = page.xpath("//form//input[@name='CONSOLE_SCALE' or @name='was-CONSOLE_SCALE']") + self.assertEqual([(f.get("type"), f.get("value")) for f in fields], + [("hidden", "False"), ("checkbox", "True"), ("hidden", "True")]) + self.assertEqual((fields[1].get("role"), fields[1].checked), ("switch", True)) + # the hidden False before it is what an unchecked switch posts; a checked one's True comes last and wins + AppSettings.objects.filter(key="CONSOLE_SCALE").update(value="False") + self.client.post(reverse("appsettings"), {"CONSOLE_SCALE": ["False", "True"], "was-CONSOLE_SCALE": "False"}) + self.assertEqual(AppSettings.objects.get(key="CONSOLE_SCALE").value, "True") + self.client.post(reverse("appsettings"), {"CONSOLE_SCALE": "False", "was-CONSOLE_SCALE": "True"}) + self.assertEqual(AppSettings.objects.get(key="CONSOLE_SCALE").value, "False") + def test_a_group_keeps_what_another_administrator_saved_meanwhile(self): # both opened the page with 5 and 15; the first saved 10 for the limit AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="10")