Phase 2: convert primitives to nodes via a whitelist element factory

Generic leaf builders (Div, Span, Td, Tr, Th, Ul, Li, Strong, Label,
Template, P) are now generated from one _html_element factory over the
single Element class — the tag name is data, not a per-tag body. Only
elements that add classes/behaviour (Button, Pill, Checkbox, Radio,
Input, A, SearchField, H1, Modal, AddForm, tables) stay hand-written.
All primitives now return Node objects; string-built widgets (Icon,
SimpleTable, YearPicker) return Safe, and YearPicker declares its
datepicker media. Raw concatenation (_popover_html, Popover slot) uses
Fragment.

Node.__str__/__html__ now return a SafeString: a node's rendered output
is safe HTML by construction, so str(node) stays safe when fed back into
a child list or template (matching the old SafeText behaviour and
preventing double-escaping).

Consumers adapted: the form widgets (SearchSelectWidget,
PrimitiveCheckboxWidget) return render(component) so Django gets a safe
string; the session form's manual field markup joins via str(row).
Component tests render nodes to HTML before asserting.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-13 07:16:59 +00:00
parent f673f3ac80
commit 4031657bb5
6 changed files with 217 additions and 205 deletions
+38 -2
View File
@@ -5,10 +5,36 @@ import django
from django.utils.safestring import SafeText, mark_safe
from common import components
from common import components as _components
from common.components.core import Node
from games.models import Platform, Game, Purchase, Session
class _RenderingComponents:
"""Test accessor that renders lazy component nodes to safe HTML strings.
Component builders now return ``Node`` objects (the lazy tree). These tests
assert on rendered HTML, so we render any node a capitalized builder returns
to a ``SafeText`` string. Internals (``_render_element``) and the legacy
string-returning ``Component()`` are untouched (non-node results pass
through), so cache/escaping tests keep working unchanged.
"""
def __getattr__(self, name):
attr = getattr(_components, name)
if not (callable(attr) and name[:1].isupper()):
return attr
def rendered(*args, **kwargs):
result = attr(*args, **kwargs)
return str(result) if isinstance(result, Node) else result
return rendered
components = _RenderingComponents()
class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent."""
@@ -822,7 +848,16 @@ class SimpleTableRenderingTest(unittest.TestCase):
from django.test import SimpleTestCase
from common.components.primitives import Checkbox, Radio
from common.components.primitives import Checkbox as _Checkbox, Radio as _Radio
# Checkbox/Radio are lazy nodes; render to safe HTML for the assertions below.
def Checkbox(*args, **kwargs):
return str(_Checkbox(*args, **kwargs))
def Radio(*args, **kwargs):
return str(_Radio(*args, **kwargs))
class ComponentPrimitivesTest(SimpleTestCase):
@@ -867,6 +902,7 @@ class PrimitiveWidgetsTest(SimpleTestCase):
def test_primitive_checkbox_widget_renders_headless(self):
from games.forms import PrimitiveCheckboxWidget
widget = PrimitiveCheckboxWidget()
html = widget.render(name="agree", value=True)
self.assertNotIn("<label", html)
+23 -15
View File
@@ -7,14 +7,30 @@ import django.test
from django.utils.safestring import SafeText
from common.components import (
FilterSelect,
Pill,
SearchSelect,
searchselect_selected,
)
from common.components import FilterSelect as _FilterSelect
from common.components import Pill as _Pill
from common.components import SearchSelect as _SearchSelect
from games.models import Game, Platform
# These components are now lazy nodes; the tests below assert on rendered HTML.
# Render at the call site so existing string assertions (assertIn / .count /
# .index / .split) keep working, and ``isinstance(..., SafeText)`` confirms the
# rendered output is safe markup.
def SearchSelect(*args, **kwargs):
return str(_SearchSelect(*args, **kwargs))
def FilterSelect(*args, **kwargs):
return str(_FilterSelect(*args, **kwargs))
def Pill(*args, **kwargs):
return str(_Pill(*args, **kwargs))
class PillTest(unittest.TestCase):
def test_returns_safetext(self):
self.assertIsInstance(Pill("hi"), SafeText)
@@ -201,9 +217,7 @@ class FilterSelectComponentTest(unittest.TestCase):
# Both the modifier pill and the value pill render.
self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html)
self.assertIn(
'data-search-select-type="include"', html
) # value pill present
self.assertIn('data-search-select-type="include"', html) # value pill present
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
@@ -250,15 +264,9 @@ class FilterSelectComponentTest(unittest.TestCase):
("INCLUDES_ONLY", "(Only)"),
],
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ALL"', html
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ONLY"', html
)
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
)
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)