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:
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user