Phase 3: declare component media that bubbles through the node tree

The JS-bearing widgets now declare their script dependencies, so a view
no longer needs to know which scripts a component requires:

- SearchSelect / FilterSelect → search_select.js
- RangeSlider → range_slider.js
- DateRangePicker → date_range_picker.js
- YearPicker → datepicker.umd.js (external, from Phase 2)
- FilterBar chrome → filter_bar.js

Because the filter-bar internals now build a node tree (the legacy
Component() string-builder calls became Element/Div), each bar's
collect_media() returns its own filter_bar.js merged with the scripts
that bubble up from the FilterSelect / RangeSlider / DateRangePicker
widgets it contains — exactly the set the views thread by hand today.

Adds Node.with_media() so a function-built node can declare media
without a full BaseComponent subclass, and tests proving the bubbling.

Note: the six *FilterBar functions still share the _filter_bar chrome
helper rather than a BaseComponent class hierarchy; folding them into
one is a follow-up that does not affect media collection (Phase 4).

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-13 07:24:29 +00:00
parent 4031657bb5
commit 0819ddb87d
5 changed files with 116 additions and 44 deletions
+18 -7
View File
@@ -110,14 +110,25 @@ class Node:
"""Total media of this node and its subtree.""" """Total media of this node and its subtree."""
return self.media return self.media
# `__html__` marks the value HTML-safe for Django (conditional_escape). def with_media(self, media: Media) -> "Node":
# `__str__` lets f-strings, ``str()`` and ``"".join(str(...))`` render the """Attach JS dependencies to this node and return it (for fluent use).
# node — the bridge that keeps most existing composition working.
def __html__(self) -> str:
return self._render()
def __str__(self) -> str: Lets a function-built node declare its media without becoming a full
return self._render() ``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
"""
self.media = self.media + media
return self
# A node's rendered output is always safe HTML by construction (Element
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
# strings). So both `__html__` (Django's conditional_escape hook) and
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
# fed back into a child list or template, matching the old SafeText shims.
def __html__(self) -> SafeText:
return mark_safe(self._render())
def __str__(self) -> SafeText:
return mark_safe(self._render())
def _child_key(child: object) -> tuple[str, bool]: def _child_key(child: object) -> tuple[str, bool]:
+14 -11
View File
@@ -19,10 +19,13 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLAttribute from common.components.core import Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Span from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts from common.time import DatePartSpec, date_parts
# Wired by date_range_picker.js.
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
_FIELD_CONTAINER_CLASS = ( _FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium " "flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text " "bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
@@ -195,8 +198,8 @@ def DateRangeField(
children=[""], children=[""],
), ),
_segment_group(side="max", label=label, iso_value=max_value), _segment_group(side="max", label=label, iso_value=max_value),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-date-range-calendar-toggle", ""), ("data-date-range-calendar-toggle", ""),
@@ -214,8 +217,8 @@ def DateRangeField(
def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText: def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
(f"data-date-range-{direction}", ""), (f"data-date-range-{direction}", ""),
@@ -227,8 +230,8 @@ def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
def _footer_button(action: str, label: str, button_class: str) -> SafeText: def _footer_button(action: str, label: str, button_class: str) -> SafeText:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
(f"data-date-range-{action}", ""), (f"data-date-range-{action}", ""),
@@ -243,8 +246,8 @@ def DateRangeCalendar(*, input_name_prefix: str) -> SafeText:
(filled client-side into ``[data-date-range-grid]``), and the (filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it.""" Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [ preset_buttons = [
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-date-range-preset", preset_value), ("data-date-range-preset", preset_value),
@@ -328,7 +331,7 @@ def DateRangePicker(
input_name_prefix: str, input_name_prefix: str,
min_value: str = "", min_value: str = "",
max_value: str = "", max_value: str = "",
) -> SafeText: ) -> Node:
"""A date-range widget: segmented manual entry plus a calendar popup. """A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
@@ -352,4 +355,4 @@ def DateRangePicker(
), ),
DateRangeCalendar(input_name_prefix=input_name_prefix), DateRangeCalendar(input_name_prefix=input_name_prefix),
], ],
) ).with_media(_DATE_RANGE_MEDIA)
+24 -17
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 Component from common.components.core import Element, Media
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 (
@@ -53,6 +53,13 @@ _FILTER_RADIO_CLASS = (
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" _FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
# range_slider.js wires RangeSlider; filter_bar.js wires the bar chrome
# (Apply/Clear, presets, search injection). Widget media (search_select.js,
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
_RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",))
_FILTER_BAR_MEDIA = Media(js=("filter_bar.js",))
def _filter_parse(filter_json: str) -> dict: def _filter_parse(filter_json: str) -> dict:
if not filter_json: if not filter_json:
return {} return {}
@@ -376,8 +383,8 @@ def RangeSlider(
("class", _RANGE_SLIDER_INPUT_CLASS), ("class", _RANGE_SLIDER_INPUT_CLASS),
], ],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
( (
@@ -482,7 +489,7 @@ def RangeSlider(
], ],
), ),
], ],
) ).with_media(_RANGE_SLIDER_MEDIA)
_DATE_RANGE_INPUT_CLASS = ( _DATE_RANGE_INPUT_CLASS = (
@@ -555,8 +562,8 @@ _FILTER_INPUT_ID = "filter-json-input"
def _filter_collapse_button() -> SafeText: def _filter_collapse_button() -> SafeText:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
# Slider handles are positioned in percentages, so initializing # Slider handles are positioned in percentages, so initializing
@@ -584,8 +591,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
return Div( return Div(
attributes=[("class", "flex gap-3 items-center")], attributes=[("class", "flex gap-3 items-center")],
children=[ children=[
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "submit"), ("type", "submit"),
( (
@@ -597,8 +604,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
], ],
children=["Apply"], children=["Apply"],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
( (
@@ -634,8 +641,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
), ),
], ],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("id", "save-preset-btn"), ("id", "save-preset-btn"),
@@ -651,8 +658,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
], ],
children=["Save Preset"], children=["Save Preset"],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("id", "confirm-save-preset-btn"), ("id", "confirm-save-preset-btn"),
@@ -706,8 +713,8 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
), ),
], ],
children=[ children=[
Component( Element(
tag_name="form", "form",
attributes=[ attributes=[
("id", _FILTER_FORM_ID), ("id", _FILTER_FORM_ID),
("onsubmit", "return applyFilterBar(event)"), ("onsubmit", "return applyFilterBar(event)"),
@@ -730,7 +737,7 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
], ],
), ),
], ],
) ).with_media(_FILTER_BAR_MEDIA)
def FilterBar( def FilterBar(
+12 -9
View File
@@ -23,9 +23,12 @@ from typing import TypedDict
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute from common.components.core import Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.primitives import Div, Input, Pill, Span, Template
# Both comboboxes are wired by search_select.js.
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
value: str | int value: str | int
@@ -322,12 +325,12 @@ def SearchSelect(
always_visible=always_visible, always_visible=always_visible,
items_visible=items_visible, items_visible=items_visible,
templates=templates, templates=templates,
) ).with_media(_SEARCH_SELECT_MEDIA)
def _filter_remove_button() -> SafeText: def _filter_remove_button() -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-pill-remove", ""), ("data-pill-remove", ""),
@@ -369,9 +372,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
) )
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText: def _filter_action_button(action: str, symbol: str, title: str) -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-search-select-action", action), ("data-search-select-action", action),
@@ -557,7 +560,7 @@ def FilterSelect(
always_visible=False, always_visible=False,
items_visible=items_visible, items_visible=items_visible,
templates=templates, templates=templates,
) ).with_media(_SEARCH_SELECT_MEDIA)
def searchselect_selected( def searchselect_selected(
+48
View File
@@ -133,5 +133,53 @@ class MediaCollectionTest(unittest.TestCase):
self.assertFalse(collect_media("just a string")) self.assertFalse(collect_media("just a string"))
class RealComponentMediaTest(unittest.TestCase):
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
def test_search_select_declares_its_script(self):
from common.components import SearchSelect
self.assertEqual(
collect_media(SearchSelect(name="games")).js, ("search_select.js",)
)
def test_filter_select_declares_its_script(self):
from common.components import FilterSelect
self.assertIn(
"search_select.js", collect_media(FilterSelect(field_name="type")).js
)
def test_date_range_picker_declares_its_script(self):
from common.components import DateRangePicker
media = collect_media(
DateRangePicker(label="Played", input_name_prefix="played")
)
self.assertEqual(media.js, ("date_range_picker.js",))
def test_range_slider_declares_its_script(self):
from common.components.filters import RangeSlider
media = collect_media(
RangeSlider(
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
)
)
self.assertEqual(media.js, ("range_slider.js",))
def test_filter_bar_collects_chrome_and_widget_media(self):
"""A FilterBar's media merges its own chrome script with the scripts that
bubble up from the FilterSelect and RangeSlider widgets it contains —
exactly the set the view used to thread by hand. (FilterBar wraps its DB
aggregates in try/except, so it builds without a database.)"""
from common.components import FilterBar
media = collect_media(FilterBar())
self.assertIn("filter_bar.js", media.js)
self.assertIn("search_select.js", media.js)
self.assertIn("range_slider.js", media.js)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()