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:
@@ -73,6 +73,7 @@ from common.components.primitives import (
|
||||
TableTd,
|
||||
Td,
|
||||
Template,
|
||||
Th,
|
||||
Tr,
|
||||
Ul,
|
||||
YearPicker,
|
||||
@@ -131,6 +132,11 @@ __all__ = [
|
||||
"Span",
|
||||
"StaticScript",
|
||||
"Label",
|
||||
"Li",
|
||||
"Td",
|
||||
"Th",
|
||||
"Tr",
|
||||
"Ul",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
|
||||
+114
-165
@@ -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.templatetags.static import static
|
||||
@@ -6,7 +13,16 @@ from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
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.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(
|
||||
id: str,
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
slot: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate popover HTML using Component(tag_name=...).
|
||||
|
||||
Single source of truth for popover HTML structure.
|
||||
Used by Popover() and the python_popover template tag bridge.
|
||||
"""
|
||||
slot: "Node | str" = "",
|
||||
) -> Node:
|
||||
"""Generate popover HTML. Single source of truth for popover structure."""
|
||||
display_content = wrapped_content if wrapped_content else slot
|
||||
|
||||
span = Span(
|
||||
@@ -79,7 +123,7 @@ def _popover_html(
|
||||
],
|
||||
)
|
||||
|
||||
return mark_safe(span + "\n" + div)
|
||||
return Fragment(span, div, separator="\n")
|
||||
|
||||
|
||||
def Popover(
|
||||
@@ -89,14 +133,14 @@ def Popover(
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
id: str = "",
|
||||
) -> str:
|
||||
) -> Node:
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
if not id:
|
||||
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(
|
||||
id=id,
|
||||
popover_content=popover_content,
|
||||
@@ -113,7 +157,7 @@ def PopoverTruncated(
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
) -> "Node | str":
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
@@ -143,7 +187,7 @@ def A(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
@@ -161,8 +205,8 @@ def A(
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
return Element(
|
||||
"a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
@@ -179,7 +223,7 @@ def Button(
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
|
||||
@@ -224,8 +268,8 @@ def Button(
|
||||
button_attrs.append(("name", name))
|
||||
button_attrs.extend(other_attrs)
|
||||
|
||||
return Component(
|
||||
tag_name="button",
|
||||
return Element(
|
||||
"button",
|
||||
attributes=button_attrs,
|
||||
children=children,
|
||||
)
|
||||
@@ -267,7 +311,7 @@ def _button_group_button(
|
||||
title: str = "",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||
|
||||
@@ -284,8 +328,8 @@ def _button_group_button(
|
||||
)
|
||||
)
|
||||
|
||||
button = Component(
|
||||
tag_name="button",
|
||||
button = Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("title", title),
|
||||
@@ -294,10 +338,10 @@ def _button_group_button(
|
||||
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.
|
||||
|
||||
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).
|
||||
"""
|
||||
buttons = buttons or []
|
||||
children: list[SafeText] = []
|
||||
children: list[Node] = []
|
||||
for btn in buttons:
|
||||
if not btn or not btn.get("slot"):
|
||||
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(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
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)
|
||||
return Element("input", attributes=attributes + [("type", type)], children=children)
|
||||
|
||||
|
||||
def Checkbox(
|
||||
@@ -407,7 +386,7 @@ def Checkbox(
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
@@ -439,7 +418,7 @@ def Radio(
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
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
|
||||
# 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
|
||||
@@ -494,7 +463,7 @@ def Pill(
|
||||
extra_class: str = "",
|
||||
label_slot: bool = False,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||
@@ -520,8 +489,8 @@ def Pill(
|
||||
children: list[HTMLTag] = [label_child]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
@@ -560,11 +529,16 @@ def StaticScript(filename: str) -> SafeText:
|
||||
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(
|
||||
year: int | None = None,
|
||||
available_years: tuple[int, ...] = (),
|
||||
url_template: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A Flowbite-datepicker year picker.
|
||||
|
||||
`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
|
||||
decoupled from the project's URL names).
|
||||
|
||||
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||
via ``render_page(scripts=...)``.
|
||||
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
|
||||
node, so ``Page()`` loads it automatically.
|
||||
"""
|
||||
label = str(year) if year is not None else "Choose a year"
|
||||
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"
|
||||
)
|
||||
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">
|
||||
<button type="button"
|
||||
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();
|
||||
}}
|
||||
}});
|
||||
</script>""")
|
||||
</script>""",
|
||||
media=_YEAR_PICKER_MEDIA,
|
||||
)
|
||||
|
||||
|
||||
def AddForm(
|
||||
@@ -648,7 +625,7 @@ def AddForm(
|
||||
fields: SafeText | str | None = None,
|
||||
additional_row: SafeText | str = "",
|
||||
submit_class: str = "mt-3",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""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
|
||||
@@ -660,8 +637,8 @@ def AddForm(
|
||||
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||
|
||||
inner_form = Component(
|
||||
tag_name="form",
|
||||
inner_form = Element(
|
||||
"form",
|
||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
@@ -689,10 +666,10 @@ def SearchField(
|
||||
search_string: str = "",
|
||||
id: str = "search_string",
|
||||
placeholder: str = "Search",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Generate a search form with icon, input field, and submit button."""
|
||||
return Component(
|
||||
tag_name="form",
|
||||
return Element(
|
||||
"form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Label(
|
||||
@@ -730,8 +707,8 @@ def SearchField(
|
||||
("required", ""),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
@@ -754,11 +731,11 @@ def SearchField(
|
||||
def H1(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
badge: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Heading with optional badge count."""
|
||||
children = children or []
|
||||
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:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
@@ -773,8 +750,8 @@ def H1(
|
||||
children=[badge],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
return Element(
|
||||
"h1",
|
||||
attributes=[("class", heading_class)],
|
||||
children=(children if isinstance(children, list) else [children])
|
||||
+ ([badge_html] if badge_html else []),
|
||||
@@ -784,10 +761,10 @@ def H1(
|
||||
def Modal(
|
||||
modal_id: str,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||
children = children or []
|
||||
outer = Div(
|
||||
return Div(
|
||||
attributes=[
|
||||
("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(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Styled table cell."""
|
||||
children = children or []
|
||||
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.
|
||||
|
||||
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"):
|
||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||
|
||||
cell_elements: list[SafeText] = []
|
||||
cell_elements: list[Node] = []
|
||||
for i, cell in enumerate(cells):
|
||||
if i == 0:
|
||||
cell_elements.append(
|
||||
@@ -910,17 +859,17 @@ def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
return mark_safe(get_icon(name))
|
||||
) -> Node:
|
||||
return Safe(get_icon(name))
|
||||
|
||||
|
||||
def TableHeader(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Table caption."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="caption",
|
||||
return Element(
|
||||
"caption",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -1011,7 +960,7 @@ def SimpleTable(
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
@@ -1030,7 +979,7 @@ def SimpleTable(
|
||||
if page_obj and elided_page_range:
|
||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||
|
||||
return mark_safe(
|
||||
return Safe(
|
||||
'<div class="shadow-md" hx-boost="false">'
|
||||
'<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">'
|
||||
@@ -1050,7 +999,7 @@ def paginated_table_content(
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||
|
||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||
|
||||
+34
-21
@@ -6,6 +6,7 @@ from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
render,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.primitives import Checkbox
|
||||
@@ -28,23 +29,32 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||
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
|
||||
return str(Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes
|
||||
))
|
||||
# render() returns a safe string (Django widgets must not be autoescaped).
|
||||
return render(
|
||||
Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PrimitiveWidgetsMixin:
|
||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
@@ -130,19 +140,22 @@ class SearchSelectWidget(forms.Widget):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||
autofocus = bool((attrs or {}).get("autofocus"))
|
||||
return SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
# Django widgets must return a safe string; the component is a node.
|
||||
return render(
|
||||
SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
)
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
|
||||
@@ -236,7 +236,7 @@ def _session_fields(form) -> SafeText:
|
||||
)
|
||||
)
|
||||
rows.append(Div(children=children))
|
||||
return mark_safe("\n".join(rows))
|
||||
return mark_safe("\n".join(str(row) for row in rows))
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user