From d7e6efa68ab1772bf0fc87a44594117e8b4328d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:11:00 +0000 Subject: [PATCH] Add FilterSelect: include/exclude combobox on the shared shell FilterSelect renders value rows with +/- (include/exclude) buttons, check/cross pills for the included/excluded sets, and an optional set of pinned modifier pseudo-options (e.g. (Any)/(None)) that stay visible above the value rows. A selected modifier is mutually exclusive with value pills. It delegates assembly to _combobox_shell and supports both pre-rendered options (complete set) and search_url + prefetch (windowed); included/excluded are passed as resolved value+label so pills show labels even outside the fetched window. Styling is inline (ported from the old SelectableFilter CSS) so nothing lives in input.css. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS --- common/components/__init__.py | 2 + common/components/search_select.py | 233 +++++++++++++++++++++++++++++ tests/test_search_select.py | 78 ++++++++++ 3 files changed, 313 insertions(+) diff --git a/common/components/__init__.py b/common/components/__init__.py index d23f226..99b535f 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -40,6 +40,7 @@ from common.components.primitives import ( paginated_table_content, ) from common.components.search_select import ( + FilterSelect, SearchSelect, SearchSelectOption, searchselect_selected, @@ -85,6 +86,7 @@ __all__ = [ "Popover", "PopoverTruncated", "SearchField", + "FilterSelect", "SearchSelect", "SearchSelectOption", "searchselect_selected", diff --git a/common/components/search_select.py b/common/components/search_select.py index baa7f9a..a899b0c 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -55,6 +55,37 @@ _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden" # used to derive the panel's max-height from items_visible. _ROW_HEIGHT_REM = 2.25 +# ── FilterSelect styling ─────────────────────────────────────────────────── +# Inline class strings (ported verbatim from the retired SelectableFilter CSS) +# so the filter combobox is fully self-styled — nothing in input.css. The +# JS-built filter rows/pills in search_select.js mirror these byte-for-byte. +_FILTER_INCLUDE_PILL_CLASS = ( + "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " + "bg-brand/15 text-heading" +) +_FILTER_EXCLUDE_PILL_CLASS = ( + "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " + "bg-red-500/15 text-red-600 line-through decoration-red-400" +) +_FILTER_MODIFIER_PILL_CLASS = ( + "inline-flex items-center px-2 py-0.5 text-sm rounded " + "bg-amber-500/15 text-amber-600 cursor-pointer" +) +_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer" +_FILTER_OPTION_ROW_CLASS = ( + "flex items-center justify-between px-2 py-1 rounded text-sm " + "hover:bg-neutral-secondary-strong cursor-pointer" +) +_FILTER_OPTION_LABEL_CLASS = "truncate" +_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0" +_FILTER_ACTION_BUTTON_CLASS = ( + "w-5 h-5 flex items-center justify-center text-xs font-bold rounded " + "border border-default-medium hover:bg-brand hover:text-white hover:border-brand" +) +_FILTER_MODIFIER_ROW_CLASS = ( + "px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer" +) + def _normalize_option(option) -> SearchSelectOption: """Coerce a dict option or a ``(value, label)`` tuple into the TypedDict.""" @@ -227,6 +258,208 @@ def SearchSelect( ) +def _filter_remove_button() -> SafeText: + return Component( + tag_name="button", + attributes=[ + ("type", "button"), + ("data-pill-remove", ""), + ("class", _FILTER_PILL_REMOVE_CLASS), + ("aria-label", "Remove"), + ], + children=["×"], + ) + + +def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText: + """An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude".""" + symbol = "✓" if kind == "include" else "✗" + css = ( + _FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS + ) + return Component( + tag_name="span", + attributes=[ + ("class", css), + ("data-pill", ""), + ("data-value", str(option["value"])), + ("data-label", option["label"]), + ("data-ss-type", kind), + *_data_attributes(option["data"]), + ], + children=[f"{symbol} {option['label']}", _filter_remove_button()], + ) + + +def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText: + """The lone, sticky modifier pill (e.g. "(Any)"/"(None)").""" + return Component( + tag_name="span", + attributes=[ + ("class", _FILTER_MODIFIER_PILL_CLASS), + ("data-pill", ""), + ("data-ss-modifier", modifier_value), + ], + children=[label, _filter_remove_button()], + ) + + +def _filter_action_button(action: str, symbol: str, title: str) -> SafeText: + return Component( + tag_name="button", + attributes=[ + ("type", "button"), + ("data-ss-action", action), + ("class", _FILTER_ACTION_BUTTON_CLASS), + ("title", title), + ], + children=[symbol], + ) + + +def _filter_option_row(value: str | int, label: str) -> SafeText: + """A value row with include (+) and exclude (−) buttons.""" + return Component( + tag_name="div", + attributes=[ + ("data-ss-option", ""), + ("data-value", str(value)), + ("data-label", label), + ("class", _FILTER_OPTION_ROW_CLASS), + ], + children=[ + Component( + tag_name="span", + attributes=[("class", _FILTER_OPTION_LABEL_CLASS)], + children=[label], + ), + Component( + tag_name="span", + attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)], + children=[ + _filter_action_button("include", "+", "Include"), + _filter_action_button("exclude", "−", "Exclude"), + ], + ), + ], + ) + + +def _filter_modifier_row(modifier_value: str, label: str) -> SafeText: + """A pinned pseudo-option row. It carries no ``data-ss-option`` so the text + filter never hides it — modifiers stay visible at the top of the panel.""" + return Component( + tag_name="div", + attributes=[ + ("data-ss-modifier-option", modifier_value), + ("data-label", label), + ("class", _FILTER_MODIFIER_ROW_CLASS), + ], + children=[label], + ) + + +def FilterSelect( + *, + field_name: str, + options: list | None = None, + included: list | None = None, + excluded: list | None = None, + modifier: str = "", + modifier_options: list[tuple[str, str]] | None = None, + search_url: str = "", + prefetch: int = 0, + items_visible: int = 6, + items_scroll: int = 10, + placeholder: str = "Search…", + id: str = "", +) -> SafeText: + """Include/exclude filter combobox built on the shared ``_combobox_shell``. + + Like ``SearchSelect`` but each value row carries +/− buttons that add an + *include* (✓) or *exclude* (✗) pill, plus an optional set of pinned + ``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``) + rendered above the value rows. A selected modifier is mutually exclusive with + value pills. State is read from the DOM into the filter JSON by + ``readSearchSelect`` (filter mode) — nothing is submitted by ``name``. + + ``included``/``excluded`` are resolved options (value + label) so pills show + labels even when the value rows come from ``search_url``. ``options`` + pre-renders the value rows for the complete-set (no ``search_url``) case. + """ + options = [_normalize_option(o) for o in (options or [])] + included = [_normalize_option(o) for o in (included or [])] + excluded = [_normalize_option(o) for o in (excluded or [])] + modifier_options = modifier_options or [] + + active_modifier_label = "" + for modifier_value, label in modifier_options: + if modifier_value == modifier: + active_modifier_label = label + break + + # ── Pills: a lone modifier pill, or include/exclude value pills ── + pills_children: list[SafeText] = [] + if active_modifier_label: + pills_children.append(_filter_modifier_pill(modifier, active_modifier_label)) + else: + for option in included: + pills_children.append(_filter_value_pill(option, "include")) + for option in excluded: + pills_children.append(_filter_value_pill(option, "exclude")) + + pills = Component( + tag_name="div", + attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)], + children=pills_children, + ) + + # ── Search box (NO name — the query is never submitted) ── + search_attributes: list[HTMLAttribute] = [ + ("data-ss-search", ""), + ("type", "text"), + ("placeholder", placeholder), + ("autocomplete", "off"), + ("class", _SEARCH_CLASS), + ] + + # ── Options: pinned modifier rows, then value rows (pre-rendered only when + # there is no search_url; otherwise the JS fetches them) ── + modifier_rows = [_filter_modifier_row(v, label) for v, label in modifier_options] + value_rows = ( + [_filter_option_row(o["value"], o["label"]) for o in options] + if not search_url + else [] + ) + + container_attributes: list[HTMLAttribute] = [ + ("data-search-select", ""), + ("data-ss-mode", "filter"), + ("data-name", field_name), + ("data-search-url", search_url), + ("data-multi", "true"), + ("data-always-visible", "false"), + ("data-items-visible", str(items_visible)), + ("data-items-scroll", str(items_scroll)), + ("data-prefetch", str(prefetch)), + ("data-sync-url", "false"), + ("class", _CONTAINER_CLASS), + ] + if modifier: + container_attributes.append(("data-modifier", modifier)) + if id: + container_attributes.append(("id", id)) + + return _combobox_shell( + container_attributes=container_attributes, + pills=pills, + search_attributes=search_attributes, + options_children=[*modifier_rows, *value_rows], + always_visible=False, + items_visible=items_visible, + ) + + def searchselect_selected( values: list, resolver: Callable[[list], Iterable[SearchSelectOption]], diff --git a/tests/test_search_select.py b/tests/test_search_select.py index e9912cc..9b7a4be 100644 --- a/tests/test_search_select.py +++ b/tests/test_search_select.py @@ -7,6 +7,7 @@ import django.test from django.utils.safestring import SafeText from common.components import ( + FilterSelect, Pill, SearchSelect, searchselect_selected, @@ -121,6 +122,83 @@ class SearchSelectComponentTest(unittest.TestCase): self.assertLess(option_row, no_results) +class FilterSelectComponentTest(unittest.TestCase): + MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")] + + def test_returns_safetext(self): + self.assertIsInstance(FilterSelect(field_name="type"), SafeText) + + def test_is_filter_mode_on_shared_shell(self): + html = FilterSelect(field_name="type") + # Reuses the SearchSelect shell (data-search-select) but flags filter mode. + self.assertIn("data-search-select", html) + self.assertIn('data-ss-mode="filter"', html) + self.assertIn('data-name="type"', html) + # No name is submitted — state is read from the DOM into the filter JSON. + self.assertEqual(html.count(' name="type"'), 0) + + def test_value_rows_have_include_exclude_buttons(self): + html = FilterSelect(field_name="type", options=[("g", "Game")]) + self.assertIn('data-ss-action="include"', html) + self.assertIn('data-ss-action="exclude"', html) + self.assertIn('data-value="g"', html) + + def test_included_renders_check_pill_excluded_renders_cross_pill(self): + html = FilterSelect( + field_name="platform", + options=[("1", "Steam"), ("2", "GOG")], + included=[("1", "Steam")], + excluded=[("2", "GOG")], + ) + self.assertIn('data-ss-type="include"', html) + self.assertIn("✓ Steam", html) + self.assertIn('data-ss-type="exclude"', html) + self.assertIn("✗ GOG", html) + self.assertIn("line-through", html) # excluded pill styling + + def test_modifier_options_render_pinned_rows(self): + html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS) + # Pinned pseudo-options carry data-ss-modifier-option, never data-ss-option, + # so the text filter leaves them visible. + self.assertIn('data-ss-modifier-option="NOT_NULL"', html) + self.assertIn('data-ss-modifier-option="IS_NULL"', html) + + def test_active_modifier_replaces_value_pills(self): + html = FilterSelect( + field_name="platform", + options=[("1", "Steam")], + included=[("1", "Steam")], + modifier="IS_NULL", + modifier_options=self.MODIFIERS, + ) + # The lone modifier pill is shown; include/exclude pills are suppressed. + self.assertIn('data-ss-modifier="IS_NULL"', html) + self.assertIn("(None)", html) + self.assertNotIn('data-ss-type="include"', html) + self.assertIn('data-modifier="IS_NULL"', html) # container carries it too + + def test_search_url_omits_value_rows_but_keeps_modifiers(self): + html = FilterSelect( + field_name="game", + search_url="/api/games/search", + prefetch=20, + modifier_options=self.MODIFIERS, + ) + self.assertNotIn('data-ss-option=""', html) # value rows fetched by JS + self.assertIn('data-ss-modifier-option="NOT_NULL"', html) # still pinned + self.assertIn('data-prefetch="20"', html) + + def test_search_url_pills_use_resolved_labels(self): + # A selected value outside the fetched window still shows its label. + html = FilterSelect( + field_name="game", + search_url="/api/games/search", + excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}], + ) + self.assertIn("✗ Obscure Game", html) + self.assertIn('data-value="4172"', html) + + class SearchLabelTest(django.test.TestCase): @classmethod def setUpTestData(cls):