diff --git a/common/components/core.py b/common/components/core.py index 864afd0..2c868af 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -110,14 +110,25 @@ class Node: """Total media of this node and its subtree.""" return self.media - # `__html__` marks the value HTML-safe for Django (conditional_escape). - # `__str__` lets f-strings, ``str()`` and ``"".join(str(...))`` render the - # node — the bridge that keeps most existing composition working. - def __html__(self) -> str: - return self._render() + def with_media(self, media: Media) -> "Node": + """Attach JS dependencies to this node and return it (for fluent use). - def __str__(self) -> str: - return self._render() + Lets a function-built node declare its media without becoming a full + ``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]: diff --git a/common/components/date_range_picker.py b/common/components/date_range_picker.py index 19be8f5..becc709 100644 --- a/common/components/date_range_picker.py +++ b/common/components/date_range_picker.py @@ -19,10 +19,13 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by 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.time import DatePartSpec, date_parts +# Wired by date_range_picker.js. +_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",)) + _FIELD_CONTAINER_CLASS = ( "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 " @@ -195,8 +198,8 @@ def DateRangeField( children=["–"], ), _segment_group(side="max", label=label, iso_value=max_value), - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "button"), ("data-date-range-calendar-toggle", ""), @@ -214,8 +217,8 @@ def DateRangeField( def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText: - return Component( - tag_name="button", + return Element( + "button", attributes=[ ("type", "button"), (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: - return Component( - tag_name="button", + return Element( + "button", attributes=[ ("type", "button"), (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 Cancel / Clear / Select footer. Hidden until the calendar toggle opens it.""" preset_buttons = [ - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "button"), ("data-date-range-preset", preset_value), @@ -328,7 +331,7 @@ def DateRangePicker( input_name_prefix: str, min_value: str = "", max_value: str = "", -) -> SafeText: +) -> Node: """A date-range widget: segmented manual entry plus a calendar popup. Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden @@ -352,4 +355,4 @@ def DateRangePicker( ), DateRangeCalendar(input_name_prefix=input_name_prefix), ], - ) + ).with_media(_DATE_RANGE_MEDIA) diff --git a/common/components/filters.py b/common/components/filters.py index 8be048c..4ec271d 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -5,7 +5,7 @@ from typing import NamedTuple from django.db import models 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.primitives import Checkbox, Div, Input, Label, Radio, Span 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" +# 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: if not filter_json: return {} @@ -376,8 +383,8 @@ def RangeSlider( ("class", _RANGE_SLIDER_INPUT_CLASS), ], ), - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "button"), ( @@ -482,7 +489,7 @@ def RangeSlider( ], ), ], - ) + ).with_media(_RANGE_SLIDER_MEDIA) _DATE_RANGE_INPUT_CLASS = ( @@ -555,8 +562,8 @@ _FILTER_INPUT_ID = "filter-json-input" def _filter_collapse_button() -> SafeText: - return Component( - tag_name="button", + return Element( + "button", attributes=[ ("type", "button"), # 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( attributes=[("class", "flex gap-3 items-center")], children=[ - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "submit"), ( @@ -597,8 +604,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: ], children=["Apply"], ), - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "button"), ( @@ -634,8 +641,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: ), ], ), - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "button"), ("id", "save-preset-btn"), @@ -651,8 +658,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: ], children=["Save Preset"], ), - Component( - tag_name="button", + Element( + "button", attributes=[ ("type", "button"), ("id", "confirm-save-preset-btn"), @@ -706,8 +713,8 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe ), ], children=[ - Component( - tag_name="form", + Element( + "form", attributes=[ ("id", _FILTER_FORM_ID), ("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( diff --git a/common/components/search_select.py b/common/components/search_select.py index 9755592..1e2f728 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -23,9 +23,12 @@ from typing import TypedDict 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 +# Both comboboxes are wired by search_select.js. +_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",)) + class SearchSelectOption(TypedDict): value: str | int @@ -322,12 +325,12 @@ def SearchSelect( always_visible=always_visible, items_visible=items_visible, templates=templates, - ) + ).with_media(_SEARCH_SELECT_MEDIA) -def _filter_remove_button() -> SafeText: - return Component( - tag_name="button", +def _filter_remove_button() -> Node: + return Element( + "button", attributes=[ ("type", "button"), ("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: - return Component( - tag_name="button", +def _filter_action_button(action: str, symbol: str, title: str) -> Node: + return Element( + "button", attributes=[ ("type", "button"), ("data-search-select-action", action), @@ -557,7 +560,7 @@ def FilterSelect( always_visible=False, items_visible=items_visible, templates=templates, - ) + ).with_media(_SEARCH_SELECT_MEDIA) def searchselect_selected( diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py index 122b24a..2b5a9ee 100644 --- a/tests/test_node_tree.py +++ b/tests/test_node_tree.py @@ -133,5 +133,53 @@ class MediaCollectionTest(unittest.TestCase): 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__": unittest.main()