From 6d21ffc4c794811f8e98379c1a301bb0e9e1334c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 9 Jun 2026 20:05:04 +0200 Subject: [PATCH] feat: add click-to-deselect behavior and update checked-radio serialization in JS --- e2e/test_boolean_filter_e2e.py | 111 +++++++++++++++++++++++++++++++++ games/static/js/filter_bar.js | 37 +++++++++-- 2 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 e2e/test_boolean_filter_e2e.py diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py new file mode 100644 index 0000000..1a22677 --- /dev/null +++ b/e2e/test_boolean_filter_e2e.py @@ -0,0 +1,111 @@ +"""End-to-end Playwright test for boolean radio filter serialization and deselect behavior. + +Covers: +1. Selecting True/False serializes the boolean field as True/False. +2. Unsetting/unchecking a radio button by clicking on it again, which deselects it, omitting the field from JSON. +""" + +import json +import urllib.parse + +import pytest +from django.http import HttpResponse +from django.test import override_settings +from django.urls import path + +from common.components import FilterBar + + +def _bar_page(filter_json: str = "") -> str: + return f""" + + + Boolean filter E2E + + + + + + {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} + +""" + + +def empty_bar_view(request): + return HttpResponse(_bar_page()) + + +urlpatterns = [ + path("test-boolean-filter/", empty_bar_view), +] + + +def _filter_from_url(url: str) -> dict: + """Extract and parse the ?filter=... query param from a URL.""" + query = urllib.parse.urlparse(url).query + params = urllib.parse.parse_qs(query) + raw = params.get("filter", [""])[0] + return json.loads(raw) if raw else {} + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e") +def test_no_selection_omits_boolean_filters(live_server, page): + page.goto(live_server.url + "/test-boolean-filter/") + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert "mastered" not in parsed + assert "purchase_refunded" not in parsed + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e") +def test_select_true_and_false_serializes_correctly(live_server, page): + page.goto(live_server.url + "/test-boolean-filter/") + + # Select "True" for Mastered + # Under PurchaseFilterBar: "filter-mastered" is the mastered radio name. + # The true radio has value="true", false radio has value="false" + true_radio = page.locator('input[name="filter-mastered"][value="true"]') + true_radio.click() + + # Select "False" for Refunded (filter-purchase-refunded) + false_radio = page.locator('input[name="filter-purchase-refunded"][value="false"]') + false_radio.click() + + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert parsed.get("mastered") == {"value": True, "modifier": "EQUALS"} + assert parsed.get("purchase_refunded") == {"value": False, "modifier": "EQUALS"} + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e") +def test_click_to_deselect_radio_works(live_server, page): + page.goto(live_server.url + "/test-boolean-filter/") + + true_radio = page.locator('input[name="filter-mastered"][value="true"]') + + # First click checks it + true_radio.click() + assert true_radio.is_checked() + + # Second click deselects it + true_radio.click() + assert not true_radio.is_checked() + + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert "mastered" not in parsed diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index 3c67690..fb96720 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -114,7 +114,7 @@ } }); - // 2. Boolean Fields (Checkboxes) + // 2. Boolean Fields (Radio Button Groups) var booleanFields = [ { name: "filter-mastered", key: "mastered" }, { name: "filter-emulated", key: "emulated" }, @@ -127,9 +127,10 @@ { name: "filter-session-emulated", key: "session_emulated" } ]; booleanFields.forEach(function (bf) { - var el = form.querySelector('[name="' + bf.name + '"]'); - if (el && el.checked) { - filter[bf.key] = criterion(true, null, "EQUALS"); + var el = form.querySelector('[name="' + bf.name + '"]:checked'); + if (el) { + var val = el.value === "true"; + filter[bf.key] = criterion(val, null, "EQUALS"); } }); @@ -400,8 +401,36 @@ } }); } + + /** + * Enable deselect-on-click behavior for filter radio buttons. + */ + function setupDeselectableRadios() { + document.querySelectorAll('input[type="radio"]').forEach(function (radio) { + radio.addEventListener('click', function (e) { + if (this.wasChecked) { + this.checked = false; + this.wasChecked = false; + this.dispatchEvent(new Event('change', { bubbles: true })); + } else { + var name = this.getAttribute('name'); + if (name) { + document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) { + r.wasChecked = false; + }); + } + this.wasChecked = true; + } + }); + if (radio.checked) { + radio.wasChecked = true; + } + }); + } + document.addEventListener("DOMContentLoaded", function () { injectSearchInputs(); + setupDeselectableRadios(); loadPresets(); }); })();