Fold the six filter bars into a BaseComponent hierarchy

The *FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar /
DeviceFilterBar / PlatformFilterBar / PlayEventFilterBar) previously shared
the collapsible chrome through a free `_filter_bar(fields, ...)` helper that
each function called at the end. Replace that with a `_FilterBarBase`
BaseComponent: it owns the chrome render() and declares `media =
_FILTER_BAR_MEDIA`, and each bar is now a subclass implementing
`build_fields()`.

The per-entity field-building bodies move verbatim into module-level
`_<entity>_fields(existing, ...)` functions that each subclass delegates to,
so the large bodies are untouched (no reindentation) and the diff stays
reviewable. Media still bubbles: BaseComponent.collect_media() merges the
bar's own filter_bar.js with the search_select.js / range_slider.js /
date_range_picker.js declared by the contained widgets.

Call sites are unchanged — `FilterBar(filter_json=..., preset_list_url=...)`
now instantiates a Node instead of calling a function, and both `str(bar)`
and `collect_media(bar)` behave as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 14:46:16 +02:00
parent 925cf007f4
commit 1c5bff8651
+131 -75
View File
@@ -5,7 +5,7 @@ from typing import NamedTuple
from django.db import models from django.db import models
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import Element, Media from common.components.core import BaseComponent, Element, Media, Node
from common.components.date_range_picker import DateRangePicker from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import ( from common.components.search_select import (
@@ -695,64 +695,104 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
) )
def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText: class _FilterBarBase(BaseComponent):
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body """Shared collapsible filter-bar chrome.
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
the hidden filter-json input and the Apply/Clear/preset action row.""" Subclasses implement ``build_fields()`` returning the per-entity body
return Div( (grids, sliders, checkboxes); this base wraps it in the collapse toggle,
attributes=[("id", "filter-bar"), ("class", "mb-6")], the form, the hidden filter-json input and the Apply/Clear/preset action
children=[ row. ``filter_bar.js`` (declared as this component's ``media``) wires the
_filter_collapse_button(), chrome; widget media (search_select.js, range_slider.js,
Div( date_range_picker.js) bubbles up from the contained widgets via the node
attributes=[ tree, so the view never threads ``scripts=`` by hand.
("id", "filter-bar-body"), """
(
"class", media = _FILTER_BAR_MEDIA
"hidden border border-default-medium rounded-base p-4 "
"bg-neutral-secondary-medium/50", def __init__(
), self,
], filter_json: str = "",
children=[ preset_list_url: str = "",
Element( preset_save_url: str = "",
"form", ) -> None:
attributes=[ self.filter_json = filter_json
("id", _FILTER_FORM_ID), self.preset_list_url = preset_list_url
("onsubmit", "return applyFilterBar(event)"), self.preset_save_url = preset_save_url
], self.existing = _filter_parse(filter_json)
children=[
Input( def build_fields(self) -> list:
attributes=[ """Return the per-entity filter body. Implemented by each subclass."""
("type", "hidden"), raise NotImplementedError
("id", _FILTER_INPUT_ID),
("name", "filter"), def render(self) -> Node:
# NB: Component escapes attribute values, so the return Div(
# raw JSON is passed through (no double-escape). attributes=[("id", "filter-bar"), ("class", "mb-6")],
("value", filter_json), children=[
], _filter_collapse_button(),
), Div(
*fields, attributes=[
_filter_action_row(preset_list_url, preset_save_url), ("id", "filter-bar-body"),
], (
), "class",
], "hidden border border-default-medium rounded-base p-4 "
), "bg-neutral-secondary-medium/50",
], ),
).with_media(_FILTER_BAR_MEDIA) ],
children=[
Element(
"form",
attributes=[
("id", _FILTER_FORM_ID),
("onsubmit", "return applyFilterBar(event)"),
],
children=[
Input(
attributes=[
("type", "hidden"),
("id", _FILTER_INPUT_ID),
("name", "filter"),
# NB: attribute values are escaped, so the
# raw JSON passes through (no double-escape).
("value", self.filter_json),
],
),
*self.build_fields(),
_filter_action_row(
self.preset_list_url, self.preset_save_url
),
],
),
],
),
],
)
def FilterBar( class FilterBar(_FilterBarBase):
filter_json: str = "",
status_options: list[LabeledOption] | None = None,
preset_list_url: str = "",
preset_save_url: str = "",
) -> SafeText:
"""Collapsible filter bar for the Game list.""" """Collapsible filter bar for the Game list."""
def __init__(
self,
filter_json: str = "",
status_options: list[LabeledOption] | None = None,
preset_list_url: str = "",
preset_save_url: str = "",
) -> None:
super().__init__(filter_json, preset_list_url, preset_save_url)
self.status_options = status_options
def build_fields(self) -> list:
return _game_fields(self.existing, self.status_options)
def _game_fields(
existing: dict, status_options: list[LabeledOption] | None = None
) -> list:
from games.models import Game, Purchase from games.models import Game, Purchase
if status_options is None: if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status] status_options = [(s.value, s.label) for s in Game.Status]
existing = _filter_parse(filter_json)
status_choice = _filter_get_choice(existing, "status") status_choice = _filter_get_choice(existing, "status")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
platform_group_choice = _filter_get_choice(existing, "platform_group") platform_group_choice = _filter_get_choice(existing, "platform_group")
@@ -1064,7 +1104,7 @@ def FilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def _find_label(options: list[LabeledOption], value: str) -> str: def _find_label(options: list[LabeledOption], value: str) -> str:
@@ -1074,13 +1114,16 @@ def _find_label(options: list[LabeledOption], value: str) -> str:
return value return value
def SessionFilterBar( class SessionFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Session list.""" """Collapsible filter bar for the Session list."""
def build_fields(self) -> list:
return _session_fields(self.existing)
def _session_fields(existing: dict) -> list:
from games.models import Game, Session from games.models import Game, Session
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game") game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device") device_choice = _filter_get_choice(existing, "device")
note_value = existing.get("note", {}).get("value", "") note_value = existing.get("note", {}).get("value", "")
@@ -1178,18 +1221,21 @@ def SessionFilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def PurchaseFilterBar( class PurchaseFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Purchase list.""" """Collapsible filter bar for the Purchase list."""
def build_fields(self) -> list:
return _purchase_fields(self.existing)
def _purchase_fields(existing: dict) -> list:
from games.models import Purchase from games.models import Purchase
type_options = Purchase.TYPES type_options = Purchase.TYPES
ownership_options = Purchase.OWNERSHIP_TYPES ownership_options = Purchase.OWNERSHIP_TYPES
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "games") game_choice = _filter_get_choice(existing, "games")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
type_choice = _filter_get_choice(existing, "type") type_choice = _filter_get_choice(existing, "type")
@@ -1361,14 +1407,19 @@ def PurchaseFilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> SafeText: class DeviceFilterBar(_FilterBarBase):
"""Collapsible filter bar for the Device list.""" """Collapsible filter bar for the Device list."""
def build_fields(self) -> list:
return _device_fields(self.existing)
def _device_fields(existing: dict) -> list:
from games.models import Device from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type") type_choice = _filter_get_choice(existing, "type")
@@ -1388,15 +1439,17 @@ def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> S
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def PlatformFilterBar( class PlatformFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list.""" """Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
def build_fields(self) -> list:
return _platform_fields(self.existing)
def _platform_fields(existing: dict) -> list:
name_value = existing.get("name", {}).get("value", "") name_value = existing.get("name", {}).get("value", "")
name_modifier = existing.get("name", {}).get("modifier", "EQUALS") name_modifier = existing.get("name", {}).get("modifier", "EQUALS")
group_value = existing.get("group", {}).get("value", "") group_value = existing.get("group", {}).get("value", "")
@@ -1427,14 +1480,17 @@ def PlatformFilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def PlayEventFilterBar( class PlayEventFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list.""" """Collapsible filter bar for the PlayEvent list."""
existing = _filter_parse(filter_json)
def build_fields(self) -> list:
return _playevent_fields(self.existing)
def _playevent_fields(existing: dict) -> list:
game_choice = _filter_get_choice(existing, "game") game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish") days_min, days_max = _parse_range(existing, "days_to_finish")
@@ -1465,7 +1521,7 @@ def PlayEventFilterBar(
max_placeholder="e.g. 30", max_placeholder="e.g. 30",
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def StringFilter( def StringFilter(