diff --git a/common/components/filters.py b/common/components/filters.py index 7f1612f..d4b2230 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -6,7 +6,7 @@ from django.db import models from django.utils.safestring import SafeText, mark_safe from common.components.core import Component -from common.components.primitives import Div, Input, Label, Span +from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span from common.components.search_select import ( DEFAULT_PREFETCH, FilterSelect, @@ -43,6 +43,12 @@ _FILTER_CHECKBOX_CLASS = ( ) +_FILTER_RADIO_CLASS = ( + "rounded-full border-default-medium bg-neutral-secondary-medium " + "text-brand focus:ring-brand" +) + + _FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" @@ -90,6 +96,24 @@ def _parse_bool(existing: dict, key: str) -> bool: return bool(field.get("value", False)) +def _parse_bool_nullable(existing: dict, key: str) -> bool | None: + """Extract a nullable boolean value from a filter criterion.""" + if key not in existing: + return None + field = existing[key] + if not isinstance(field, dict): + return None + val = field.get("value") + if val is None: + return None + if isinstance(val, str): + if val.lower() in ("true", "1", "yes"): + return True + if val.lower() in ("false", "0", "no"): + return False + return bool(val) + + # ── FilterSelect adapters ──────────────────────────────────────────────────── # Each list filter is a FilterSelect. Enum fields pre-render their small, fixed # option set; model-backed fields fetch from a search endpoint on demand, with @@ -231,19 +255,26 @@ def _filter_field(label: str, widget, for_widget: str = None) -> SafeText: def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: - return Label( - attributes=[("class", "flex items-center gap-2 text-sm text-heading")], + """Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive.""" + return Checkbox(name=name, label=label, checked=checked) + + +def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText: + """Renders a filter-specific boolean radio button group with 'True' and 'False' options.""" + return Div( + attributes=[("class", "flex flex-col gap-1")], children=[ - Input( - attributes=[ - ("type", "checkbox"), - ("name", name), - ("value", "1"), - *([("checked", "true")] if checked else []), - ("class", _FILTER_CHECKBOX_CLASS), + Span( + attributes=[("class", _FILTER_LABEL_CLASS)], + children=[label], + ), + Div( + attributes=[("class", "flex items-center gap-4 h-9")], + children=[ + Radio(name=name, label="True", checked=value is True, value="true"), + Radio(name=name, label="False", checked=value is False, value="false"), ], ), - label, ], ) diff --git a/tests/test_filter_helpers.py b/tests/test_filter_helpers.py index 96381d1..b073f94 100644 --- a/tests/test_filter_helpers.py +++ b/tests/test_filter_helpers.py @@ -2,7 +2,7 @@ from django.test import SimpleTestCase -from common.components.filters import _parse_bool, _parse_range +from common.components.filters import _parse_bool, _parse_range, _parse_bool_nullable class ParseRangeTest(SimpleTestCase): @@ -66,3 +66,23 @@ class ParseBoolTest(SimpleTestCase): def test_missing_value_in_field(self): self.assertFalse(_parse_bool({"field": {}}, "field")) + + +class ParseBoolNullableTest(SimpleTestCase): + def test_missing_key(self): + self.assertIsNone(_parse_bool_nullable({}, "field")) + + def test_null_value(self): + self.assertIsNone(_parse_bool_nullable({"field": None}, "field")) + self.assertIsNone(_parse_bool_nullable({"field": {}}, "field")) + + def test_boolean_values(self): + self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field")) + self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field")) + + def test_string_values(self): + self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field")) + self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field")) + self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field")) + self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field")) +