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