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
+6
View File
@@ -73,6 +73,7 @@ from common.components.primitives import (
TableTd, TableTd,
Td, Td,
Template, Template,
Th,
Tr, Tr,
Ul, Ul,
YearPicker, YearPicker,
@@ -131,6 +132,11 @@ __all__ = [
"Span", "Span",
"StaticScript", "StaticScript",
"Label", "Label",
"Li",
"Td",
"Th",
"Tr",
"Ul",
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
+114 -165
View File
@@ -1,4 +1,11 @@
"""Generic HTML primitives (no domain knowledge).""" """Generic HTML primitives (no domain knowledge).
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
per tag: they are generated from a whitelist via :func:`_html_element`, each a
thin builder over the single :class:`Element` node class. Only elements that add
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
"""
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
@@ -6,7 +13,16 @@ from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid from common.components.core import (
Element,
Fragment,
HTMLAttribute,
HTMLTag,
Media,
Node,
Safe,
randomid,
)
from common.icons import get_icon from common.icons import get_icon
from common.utils import truncate from common.utils import truncate
@@ -27,18 +43,46 @@ _SIZE_CLASSES = {
} }
# ── Generic leaf elements ────────────────────────────────────────────────────
# A whitelist of plain tags, each turned into a builder over `Element`. The
# tag name is data, not a separate class/function body. Add a tag = one line.
def _html_element(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
def element(
attributes: list[HTMLAttribute] | None = None,
children: "list[HTMLTag] | HTMLTag | Node | None" = None,
) -> Element:
return Element(tag_name, attributes, children)
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
return element
Div = _html_element("div")
P = _html_element("p")
Ul = _html_element("ul")
Li = _html_element("li")
Strong = _html_element("strong")
Span = _html_element("span")
Label = _html_element("label")
Template = _html_element("template")
Td = _html_element("td")
Tr = _html_element("tr")
Th = _html_element("th")
def _popover_html( def _popover_html(
id: str, id: str,
popover_content: str, popover_content: str,
wrapped_content: str = "", wrapped_content: str = "",
wrapped_classes: str = "", wrapped_classes: str = "",
slot: str = "", slot: "Node | str" = "",
) -> SafeText: ) -> Node:
"""Generate popover HTML using Component(tag_name=...). """Generate popover HTML. Single source of truth for popover structure."""
Single source of truth for popover HTML structure.
Used by Popover() and the python_popover template tag bridge.
"""
display_content = wrapped_content if wrapped_content else slot display_content = wrapped_content if wrapped_content else slot
span = Span( span = Span(
@@ -79,7 +123,7 @@ def _popover_html(
], ],
) )
return mark_safe(span + "\n" + div) return Fragment(span, div, separator="\n")
def Popover( def Popover(
@@ -89,14 +133,14 @@ def Popover(
children: list[HTMLTag] | None = None, children: list[HTMLTag] | None = None,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
id: str = "", id: str = "",
) -> str: ) -> Node:
children = children or [] children = children or []
if not wrapped_content and not children: if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.") raise ValueError("One of wrapped_content or children is required.")
if not id: if not id:
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
slot = mark_safe("\n".join(children)) slot = Fragment(*children, separator="\n") if children else ""
return _popover_html( return _popover_html(
id=id, id=id,
popover_content=popover_content, popover_content=popover_content,
@@ -113,7 +157,7 @@ def PopoverTruncated(
length: int = 30, length: int = 30,
ellipsis: str = "", ellipsis: str = "",
endpart: str = "", endpart: str = "",
) -> str: ) -> "Node | str":
""" """
Returns `input_string` truncated after `length` of characters Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element. and displays the untruncated text in a popover HTML element.
@@ -143,7 +187,7 @@ def A(
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
url_name: str | None = None, url_name: str | None = None,
href: str | None = None, href: str | None = None,
) -> SafeText: ) -> Element:
""" """
Returns an anchor <a> tag. Returns an anchor <a> tag.
@@ -161,8 +205,8 @@ def A(
additional_attributes = [("href", reverse(url_name))] additional_attributes = [("href", reverse(url_name))]
elif href is not None: elif href is not None:
additional_attributes = [("href", href)] additional_attributes = [("href", href)]
return Component( return Element(
tag_name="a", attributes=attributes + additional_attributes, children=children "a", attributes=attributes + additional_attributes, children=children
) )
@@ -179,7 +223,7 @@ def Button(
title: str = "", title: str = "",
onclick: str = "", onclick: str = "",
name: str = "", name: str = "",
) -> SafeText: ) -> Element:
attributes = attributes or [] attributes = attributes or []
children = children or [] children = children or []
@@ -224,8 +268,8 @@ def Button(
button_attrs.append(("name", name)) button_attrs.append(("name", name))
button_attrs.extend(other_attrs) button_attrs.extend(other_attrs)
return Component( return Element(
tag_name="button", "button",
attributes=button_attrs, attributes=button_attrs,
children=children, children=children,
) )
@@ -267,7 +311,7 @@ def _button_group_button(
title: str = "", title: str = "",
hx_get: str = "", hx_get: str = "",
hx_target: str = "", hx_target: str = "",
) -> SafeText: ) -> Element:
"""Generate a single button-group button (inner <button> inside <a>).""" """Generate a single button-group button (inner <button> inside <a>)."""
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"]) color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
@@ -284,8 +328,8 @@ def _button_group_button(
) )
) )
button = Component( button = Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("title", title), ("title", title),
@@ -294,10 +338,10 @@ def _button_group_button(
children=[slot], children=[slot],
) )
return Component(tag_name="a", attributes=a_attrs, children=[button]) return Element("a", attributes=a_attrs, children=[button])
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText: def ButtonGroup(buttons: list[dict] | None = None) -> Element:
"""Generate a button group div. """Generate a button group div.
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target. Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
@@ -305,7 +349,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
for conditional buttons (e.g., end-session only when session is active). for conditional buttons (e.g., end-session only when session is active).
""" """
buttons = buttons or [] buttons = buttons or []
children: list[SafeText] = [] children: list[Node] = []
for btn in buttons: for btn in buttons:
if not btn or not btn.get("slot"): if not btn or not btn.get("slot"):
continue continue
@@ -326,79 +370,14 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
) )
def Div(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children)
def P(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="p", attributes=attributes, children=children)
def Ul(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="ul", attributes=attributes, children=children)
def Li(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="li", attributes=attributes, children=children)
def Strong(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="strong", attributes=attributes, children=children)
def Input( def Input(
type: str = "text", type: str = "text",
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText: ) -> Element:
attributes = attributes or [] attributes = attributes or []
children = children or [] children = children or []
return Component( return Element("input", attributes=attributes + [("type", type)], children=children)
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Span(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="span", attributes=attributes, children=children)
def Label(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="label", attributes=attributes, children=children)
def Checkbox( def Checkbox(
@@ -407,7 +386,7 @@ def Checkbox(
checked: bool = False, checked: bool = False,
value: str = "1", value: str = "1",
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
) -> SafeText: ) -> Node:
"""A filter-agnostic Checkbox component.""" """A filter-agnostic Checkbox component."""
attributes = attributes or [] attributes = attributes or []
input_attrs = [ input_attrs = [
@@ -439,7 +418,7 @@ def Radio(
checked: bool = False, checked: bool = False,
value: str = "", value: str = "",
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
) -> SafeText: ) -> Node:
"""A filter-agnostic Radio component.""" """A filter-agnostic Radio component."""
attributes = attributes or [] attributes = attributes or []
input_attrs = [ input_attrs = [
@@ -465,16 +444,6 @@ def Radio(
) )
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
attributes = attributes or []
children = children or []
return Component(tag_name="template", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in # Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
# input.css, written inline so styling stays encapsulated in the component). The # input.css, written inline so styling stays encapsulated in the component). The
# JS that builds pills client-side (search_select.js) MUST emit these exact class # JS that builds pills client-side (search_select.js) MUST emit these exact class
@@ -494,7 +463,7 @@ def Pill(
extra_class: str = "", extra_class: str = "",
label_slot: bool = False, label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
) -> SafeText: ) -> Node:
"""A small label pill, optionally removable (× button). """A small label pill, optionally removable (× button).
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove`` Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
@@ -520,8 +489,8 @@ def Pill(
children: list[HTMLTag] = [label_child] children: list[HTMLTag] = [label_child]
if removable: if removable:
children.append( children.append(
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-pill-remove", ""), ("data-pill-remove", ""),
@@ -560,11 +529,16 @@ def StaticScript(filename: str) -> SafeText:
return mark_safe(f'<script src="{static("js/" + filename)}"></script>') return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
# Media for the Flowbite-datepicker year picker (vendored UMD bundle). Declared
# on the YearPicker node so Page() loads it wherever a YearPicker appears.
_YEAR_PICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
def YearPicker( def YearPicker(
year: int | None = None, year: int | None = None,
available_years: tuple[int, ...] = (), available_years: tuple[int, ...] = (),
url_template: str = "", url_template: str = "",
) -> SafeText: ) -> Node:
"""A Flowbite-datepicker year picker. """A Flowbite-datepicker year picker.
`year` is the selected year, or ``None`` for the all-time view (the empty `year` is the selected year, or ``None`` for the all-time view (the empty
@@ -573,8 +547,8 @@ def YearPicker(
placeholder, substituted with the chosen year in JS (keeps this component placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names). decoupled from the project's URL names).
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
via ``render_page(scripts=...)``. node, so ``Page()`` loads it automatically.
""" """
label = str(year) if year is not None else "Choose a year" label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else "" selected = str(year) if year is not None else ""
@@ -585,7 +559,8 @@ def YearPicker(
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium" "hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
) )
years_csv = ",".join(str(y) for y in available_years) years_csv = ",".join(str(y) for y in available_years)
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}" return Safe(
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
@keydown.escape.window="pickerOpen = false"> @keydown.escape.window="pickerOpen = false">
<button type="button" <button type="button"
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())" x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
@@ -638,7 +613,9 @@ document.addEventListener('DOMContentLoaded', () => {{
picker.update(); picker.update();
}} }}
}}); }});
</script>""") </script>""",
media=_YEAR_PICKER_MEDIA,
)
def AddForm( def AddForm(
@@ -648,7 +625,7 @@ def AddForm(
fields: SafeText | str | None = None, fields: SafeText | str | None = None,
additional_row: SafeText | str = "", additional_row: SafeText | str = "",
submit_class: str = "mt-3", submit_class: str = "mt-3",
) -> SafeText: ) -> Node:
"""Page body for the generic add/edit form (Python equivalent of add.html). """Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the `fields` overrides the default ``form.as_div()`` field markup (used by the
@@ -660,8 +637,8 @@ def AddForm(
field_markup = fields if fields is not None else mark_safe(form.as_div()) field_markup = fields if fields is not None else mark_safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else [] submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Component( inner_form = Element(
tag_name="form", "form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")], attributes=[("method", "post"), ("enctype", "multipart/form-data")],
children=[ children=[
CsrfInput(request), CsrfInput(request),
@@ -689,10 +666,10 @@ def SearchField(
search_string: str = "", search_string: str = "",
id: str = "search_string", id: str = "search_string",
placeholder: str = "Search", placeholder: str = "Search",
) -> SafeText: ) -> Element:
"""Generate a search form with icon, input field, and submit button.""" """Generate a search form with icon, input field, and submit button."""
return Component( return Element(
tag_name="form", "form",
attributes=[("class", "max-w-md")], attributes=[("class", "max-w-md")],
children=[ children=[
Label( Label(
@@ -730,8 +707,8 @@ def SearchField(
("required", ""), ("required", ""),
], ],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "submit"), ("type", "submit"),
( (
@@ -754,11 +731,11 @@ def SearchField(
def H1( def H1(
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
badge: str = "", badge: str = "",
) -> SafeText: ) -> Element:
"""Heading with optional badge count.""" """Heading with optional badge count."""
children = children or [] children = children or []
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white" heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
badge_html = "" badge_html: Node | str = ""
if badge: if badge:
heading_class = "flex items-center " + heading_class heading_class = "flex items-center " + heading_class
@@ -773,8 +750,8 @@ def H1(
children=[badge], children=[badge],
) )
return Component( return Element(
tag_name="h1", "h1",
attributes=[("class", heading_class)], attributes=[("class", heading_class)],
children=(children if isinstance(children, list) else [children]) children=(children if isinstance(children, list) else [children])
+ ([badge_html] if badge_html else []), + ([badge_html] if badge_html else []),
@@ -784,10 +761,10 @@ def H1(
def Modal( def Modal(
modal_id: str, modal_id: str,
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText: ) -> Node:
"""Modal overlay with container. Content (form, buttons) goes in children.""" """Modal overlay with container. Content (form, buttons) goes in children."""
children = children or [] children = children or []
outer = Div( return Div(
attributes=[ attributes=[
("id", modal_id), ("id", modal_id),
( (
@@ -809,39 +786,11 @@ def Modal(
), ),
], ],
) )
return mark_safe(str(outer))
def Td(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="td", attributes=attributes, children=children)
def Tr(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="tr", attributes=attributes, children=children)
def Th(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="th", attributes=attributes, children=children)
def TableTd( def TableTd(
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText: ) -> Element:
"""Styled table cell.""" """Styled table cell."""
children = children or [] children = children or []
return Td( return Td(
@@ -850,7 +799,7 @@ def TableTd(
) )
def TableRow(data: dict | list | None = None) -> SafeText: def TableRow(data: dict | list | None = None) -> Element:
"""Generate a <tr> from a row data dict or list. """Generate a <tr> from a row data dict or list.
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...} Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
@@ -885,7 +834,7 @@ def TableRow(data: dict | list | None = None) -> SafeText:
if data.get("hx_swap"): if data.get("hx_swap"):
tr_attrs.append(("hx-swap", data["hx_swap"])) tr_attrs.append(("hx-swap", data["hx_swap"]))
cell_elements: list[SafeText] = [] cell_elements: list[Node] = []
for i, cell in enumerate(cells): for i, cell in enumerate(cells):
if i == 0: if i == 0:
cell_elements.append( cell_elements.append(
@@ -910,17 +859,17 @@ def TableRow(data: dict | list | None = None) -> SafeText:
def Icon( def Icon(
name: str, name: str,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
) -> SafeText: ) -> Node:
return mark_safe(get_icon(name)) return Safe(get_icon(name))
def TableHeader( def TableHeader(
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText: ) -> Element:
"""Table caption.""" """Table caption."""
children = children or [] children = children or []
return Component( return Element(
tag_name="caption", "caption",
attributes=[ attributes=[
( (
"class", "class",
@@ -1011,7 +960,7 @@ def SimpleTable(
page_obj=None, page_obj=None,
elided_page_range=None, elided_page_range=None,
request=None, request=None,
) -> SafeText: ) -> Node:
"""Paginated table. Python equivalent of the old simple_table.html.""" """Paginated table. Python equivalent of the old simple_table.html."""
columns = columns or [] columns = columns or []
rows = rows or [] rows = rows or []
@@ -1030,7 +979,7 @@ def SimpleTable(
if page_obj and elided_page_range: if page_obj and elided_page_range:
pagination_html = _pagination_nav(page_obj, elided_page_range, request) pagination_html = _pagination_nav(page_obj, elided_page_range, request)
return mark_safe( return Safe(
'<div class="shadow-md" hx-boost="false">' '<div class="shadow-md" hx-boost="false">'
'<div class="relative overflow-x-auto sm:rounded-t-lg">' '<div class="relative overflow-x-auto sm:rounded-t-lg">'
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">' '<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
@@ -1050,7 +999,7 @@ def paginated_table_content(
page_obj=None, page_obj=None,
elided_page_range=None, elided_page_range=None,
request=None, request=None,
) -> SafeText: ) -> Node:
"""Standard list-page body: a max-width Div wrapping a SimpleTable. """Standard list-page body: a max-width Div wrapping a SimpleTable.
`data` is the table dict with keys ``columns``, ``rows`` and `data` is the table dict with keys ``columns``, ``rows`` and
+18 -5
View File
@@ -6,6 +6,7 @@ from common.components import (
DEFAULT_PREFETCH, DEFAULT_PREFETCH,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
render,
searchselect_selected, searchselect_selected,
) )
from common.components.primitives import Checkbox from common.components.primitives import Checkbox
@@ -28,23 +29,32 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class PrimitiveCheckboxWidget(forms.CheckboxInput): class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component.""" """Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs) final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value) checked = self.check_test(value)
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")] attributes = [
(k, str(v))
for k, v in final_attrs.items()
if k not in ("type", "name", "value", "checked")
]
# Django uses boolean values differently for checkboxes, we omit value if empty # Django uses boolean values differently for checkboxes, we omit value if empty
return str(Checkbox( # render() returns a safe string (Django widgets must not be autoescaped).
return render(
Checkbox(
name=name, name=name,
label=None, label=None,
checked=checked, checked=checked,
value=str(value) if value else "1", value=str(value) if value else "1",
attributes=attributes attributes=attributes,
)) )
)
class PrimitiveWidgetsMixin: class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields.""" """Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field_name, field in self.fields.items(): for field_name, field in self.fields.items():
@@ -130,7 +140,9 @@ class SearchSelectWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver) selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus")) autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect( # Django widgets must return a safe string; the component is a node.
return render(
SearchSelect(
name=name, name=name,
selected=selected, selected=selected,
options=None, options=None,
@@ -144,6 +156,7 @@ class SearchSelectWidget(forms.Widget):
id=(attrs or {}).get("id", ""), id=(attrs or {}).get("id", ""),
autofocus=autofocus, autofocus=autofocus,
) )
)
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
return data.get(name) return data.get(name)
+1 -1
View File
@@ -236,7 +236,7 @@ def _session_fields(form) -> SafeText:
) )
) )
rows.append(Div(children=children)) rows.append(Div(children=children))
return mark_safe("\n".join(rows)) return mark_safe("\n".join(str(row) for row in rows))
@login_required @login_required
+38 -2
View File
@@ -5,10 +5,36 @@ import django
from django.utils.safestring import SafeText, mark_safe 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 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): class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent.""" """Test Component() works correctly with caching transparent."""
@@ -822,7 +848,16 @@ class SimpleTableRenderingTest(unittest.TestCase):
from django.test import SimpleTestCase 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): class ComponentPrimitivesTest(SimpleTestCase):
@@ -867,6 +902,7 @@ class PrimitiveWidgetsTest(SimpleTestCase):
def test_primitive_checkbox_widget_renders_headless(self): def test_primitive_checkbox_widget_renders_headless(self):
from games.forms import PrimitiveCheckboxWidget from games.forms import PrimitiveCheckboxWidget
widget = PrimitiveCheckboxWidget() widget = PrimitiveCheckboxWidget()
html = widget.render(name="agree", value=True) html = widget.render(name="agree", value=True)
self.assertNotIn("<label", html) self.assertNotIn("<label", html)
+23 -15
View File
@@ -7,14 +7,30 @@ import django.test
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components import ( from common.components import (
FilterSelect,
Pill,
SearchSelect,
searchselect_selected, 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 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): class PillTest(unittest.TestCase):
def test_returns_safetext(self): def test_returns_safetext(self):
self.assertIsInstance(Pill("hi"), SafeText) self.assertIsInstance(Pill("hi"), SafeText)
@@ -201,9 +217,7 @@ class FilterSelectComponentTest(unittest.TestCase):
# Both the modifier pill and the value pill render. # Both the modifier pill and the value pill render.
self.assertIn('data-search-select-modifier="IS_NULL"', html) self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html) self.assertIn("(None)", html)
self.assertIn( self.assertIn('data-search-select-type="include"', html) # value pill present
'data-search-select-type="include"', html
) # value pill present
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self): def test_search_url_omits_value_rows_but_keeps_modifiers(self):
@@ -250,15 +264,9 @@ class FilterSelectComponentTest(unittest.TestCase):
("INCLUDES_ONLY", "(Only)"), ("INCLUDES_ONLY", "(Only)"),
], ],
) )
self.assertIn( self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
'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_ONLY"', html
)
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
)
# No legacy match-mode <select>. # No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html) self.assertNotIn("data-search-select-match", html)