Migrate remaining Component() callers to Element; delete the shim
The legacy back-compat ``Component(tag_name=...)`` function (a thin
string-returning wrapper over ``Element``) was the last piece of the
pre-node-tree API. Migrate its ~18 call sites across the views to the node
builders and remove it:
- stats_content.py: the table helpers now use the whitelisted ``Td`` / ``Th``
/ ``Tr`` builders and ``Element`` for table/tbody/thead/h1; helper return
types are ``Node``.
- auth.py / statuschange.py / game.py / purchase.py: the hand-built
``<form>`` / ``<button>`` / ``<h1>`` / ``<h2>`` / ``<table>`` markup now uses
``Element("tag", ...)``.
- core.py: drop the ``Component()`` function and its back-compat note;
``common/components/__init__`` no longer exports it.
- Tests that exercised the shim now target ``Element`` directly
(test_components cache/escaping/edge-case classes; test_node_tree drops the
legacy-parity and legacy-bridge cases, which ``Element`` coverage subsumes).
- CLAUDE.md: drop the "legacy Component retained for back-compat" notes.
Full suite green (443; one obsolete legacy-bridge test removed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ docs/ — Additional documentation
|
|||||||
|
|
||||||
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
|
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
|
||||||
|
|
||||||
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped; children escaped unless `SafeText`/`Node`. The legacy `Component(tag_name=...)` function still returns a `SafeText` string (Node-aware children) for back-compat. `randomid()` generates stable hash-based IDs.
|
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped; children escaped unless `SafeText`/`Node`. `randomid()` generates stable hash-based IDs.
|
||||||
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
|
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
|
||||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||||
@@ -166,7 +166,7 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
|
|||||||
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
||||||
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
||||||
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
||||||
- **Components are nodes; use the named builders, not the legacy `Component()`** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue). The legacy `Component(tag_name=…)` function is retained for back-compat only.
|
- **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
|
||||||
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
||||||
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
||||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ re-exports the public API so ``from common.components import X`` keeps working.
|
|||||||
|
|
||||||
from common.components.core import (
|
from common.components.core import (
|
||||||
BaseComponent,
|
BaseComponent,
|
||||||
Component,
|
|
||||||
Element,
|
Element,
|
||||||
Fragment,
|
Fragment,
|
||||||
HTMLAttribute,
|
HTMLAttribute,
|
||||||
@@ -92,7 +91,6 @@ from common.utils import truncate
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"truncate",
|
"truncate",
|
||||||
"BaseComponent",
|
"BaseComponent",
|
||||||
"Component",
|
|
||||||
"Element",
|
"Element",
|
||||||
"Fragment",
|
"Fragment",
|
||||||
"Media",
|
"Media",
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ Nodes are *lazy*: they hold structure and render to HTML only when asked
|
|||||||
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
|
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
|
||||||
``Page()`` walk a finished tree and collect every component's declared JS
|
``Page()`` walk a finished tree and collect every component's declared JS
|
||||||
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
|
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
|
||||||
|
|
||||||
Backwards compatibility: the legacy ``Component(tag_name=...)`` function still
|
|
||||||
returns a ``SafeText`` string, so existing string-based call sites keep working
|
|
||||||
during the migration. Its child handling is Node-aware, so a tree mixing old
|
|
||||||
(string-returning) and new (node-returning) components renders correctly.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -288,20 +283,6 @@ def collect_media(node: "Node | str") -> Media:
|
|||||||
return Media()
|
return Media()
|
||||||
|
|
||||||
|
|
||||||
def Component(
|
|
||||||
attributes: list[HTMLAttribute] | None = None,
|
|
||||||
children: "list[HTMLTag] | HTMLTag | None" = None,
|
|
||||||
tag_name: str = "",
|
|
||||||
) -> SafeText:
|
|
||||||
"""Legacy element builder: returns a ``SafeText`` string.
|
|
||||||
|
|
||||||
Kept for backwards compatibility while call sites migrate to :class:`Element`
|
|
||||||
and the generated tag builders. Child handling is Node-aware, so a tree that
|
|
||||||
mixes string-returning and node-returning components still renders correctly.
|
|
||||||
"""
|
|
||||||
return render(Element(tag_name, attributes, children))
|
|
||||||
|
|
||||||
|
|
||||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||||
if not seed and not content:
|
if not seed and not content:
|
||||||
return seed
|
return seed
|
||||||
|
|||||||
+9
-9
@@ -3,16 +3,16 @@ registration/login.html)."""
|
|||||||
|
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import Component, CsrfInput, Div, Input
|
from common.components import CsrfInput, Div, Element, Input, Node
|
||||||
from common.components.primitives import Td, Tr
|
from common.components.primitives import Td, Tr
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
|
|
||||||
|
|
||||||
def _login_content(form, request) -> SafeText:
|
def _login_content(form, request) -> Node:
|
||||||
table = Component(
|
table = Element(
|
||||||
tag_name="table",
|
"table",
|
||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
mark_safe(str(form.as_table())),
|
mark_safe(str(form.as_table())),
|
||||||
@@ -31,13 +31,13 @@ def _login_content(form, request) -> SafeText:
|
|||||||
return Div(
|
return Div(
|
||||||
[("class", "flex items-center flex-col")],
|
[("class", "flex items-center flex-col")],
|
||||||
[
|
[
|
||||||
Component(
|
Element(
|
||||||
tag_name="h2",
|
"h2",
|
||||||
attributes=[("class", "text-3xl text-white mb-8")],
|
attributes=[("class", "text-3xl text-white mb-8")],
|
||||||
children=["Please log in to continue"],
|
children=["Please log in to continue"],
|
||||||
),
|
),
|
||||||
Component(
|
Element(
|
||||||
tag_name="form",
|
"form",
|
||||||
attributes=[("method", "post")],
|
attributes=[("method", "post")],
|
||||||
children=[table],
|
children=[table],
|
||||||
),
|
),
|
||||||
|
|||||||
+7
-7
@@ -17,9 +17,9 @@ from common.components import (
|
|||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Component,
|
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
|
Element,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
GameStatus,
|
GameStatus,
|
||||||
GameStatusSelector,
|
GameStatusSelector,
|
||||||
@@ -201,8 +201,8 @@ def _delete_game_confirmation_modal(
|
|||||||
if not (session_count or purchase_count or playevent_count):
|
if not (session_count or purchase_count or playevent_count):
|
||||||
data_items.append(Li(children=["No associated data"]))
|
data_items.append(Li(children=["No associated data"]))
|
||||||
|
|
||||||
form = Component(
|
form = Element(
|
||||||
tag_name="form",
|
"form",
|
||||||
attributes=[
|
attributes=[
|
||||||
("hx-post", reverse("games:delete_game", args=[game.id])),
|
("hx-post", reverse("games:delete_game", args=[game.id])),
|
||||||
("hx-replace-url", "true"),
|
("hx-replace-url", "true"),
|
||||||
@@ -442,8 +442,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
|||||||
edit_link = A(
|
edit_link = A(
|
||||||
href=reverse("games:edit_game", args=[game.id]),
|
href=reverse("games:edit_game", args=[game.id]),
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Element(
|
||||||
tag_name="button",
|
"button",
|
||||||
attributes=[("type", "button"), ("class", edit_class)],
|
attributes=[("type", "button"), ("class", edit_class)],
|
||||||
children=["Edit"],
|
children=["Edit"],
|
||||||
)
|
)
|
||||||
@@ -456,8 +456,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
|||||||
("hx-target", "#global-modal-container"),
|
("hx-target", "#global-modal-container"),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Element(
|
||||||
tag_name="button",
|
"button",
|
||||||
attributes=[("type", "button"), ("class", delete_class)],
|
attributes=[("type", "button"), ("class", delete_class)],
|
||||||
children=["Delete"],
|
children=["Delete"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ from common.components import (
|
|||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Component,
|
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
|
Element,
|
||||||
GameLink,
|
GameLink,
|
||||||
Icon,
|
Icon,
|
||||||
LinkedPurchase,
|
LinkedPurchase,
|
||||||
Modal,
|
Modal,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
|
Node,
|
||||||
PriceConverted,
|
PriceConverted,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
TableRow,
|
TableRow,
|
||||||
@@ -301,9 +302,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
return redirect("games:list_purchases")
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||||
form = Component(
|
form = Element(
|
||||||
tag_name="form",
|
"form",
|
||||||
attributes=[
|
attributes=[
|
||||||
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||||
("hx-target", f"#purchase-row-{purchase_id}"),
|
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||||
@@ -339,8 +340,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
|||||||
return Modal(
|
return Modal(
|
||||||
"refund-confirmation-modal",
|
"refund-confirmation-modal",
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Element(
|
||||||
tag_name="h1",
|
"h1",
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from django.template.defaultfilters import date as date_filter
|
|||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.urls import reverse
|
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 mark_safe
|
||||||
|
|
||||||
from common.components import A, Component, Div, GameLink, YearPicker
|
from common.components import A, Div, Element, GameLink, Node, Td, Th, Tr, YearPicker
|
||||||
from common.time import durationformat, format_duration
|
from common.time import durationformat, format_duration
|
||||||
|
|
||||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||||
@@ -19,41 +19,40 @@ _CELL_MONO = f"{_CELL} font-mono"
|
|||||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||||
|
|
||||||
|
|
||||||
def _td(children, cls: str = _CELL_MONO) -> SafeText:
|
def _td(children, cls: str = _CELL_MONO) -> Node:
|
||||||
if not isinstance(children, list):
|
if not isinstance(children, list):
|
||||||
children = [children]
|
children = [children]
|
||||||
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
|
return Td(attributes=[("class", cls)], children=children)
|
||||||
return Component(tag_name="td", attributes=[("class", cls)], children=children)
|
|
||||||
|
|
||||||
|
|
||||||
def _th(text: str, cls: str = _CELL) -> SafeText:
|
def _th(text: str, cls: str = _CELL) -> Node:
|
||||||
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
|
return Th(attributes=[("class", cls)], children=[text])
|
||||||
|
|
||||||
|
|
||||||
def _tr(cells: list) -> SafeText:
|
def _tr(cells: list) -> Node:
|
||||||
return Component(tag_name="tr", children=cells)
|
return Tr(children=cells)
|
||||||
|
|
||||||
|
|
||||||
def _kv(label, value) -> SafeText:
|
def _kv(label, value) -> Node:
|
||||||
"""A label/value row: plain label cell + mono value cell."""
|
"""A label/value row: plain label cell + mono value cell."""
|
||||||
return _tr([_td(label, _CELL), _td(value)])
|
return _tr([_td(label, _CELL), _td(value)])
|
||||||
|
|
||||||
|
|
||||||
def _h1(title: str) -> SafeText:
|
def _h1(title: str) -> Node:
|
||||||
return Component(
|
return Element(
|
||||||
tag_name="h1",
|
"h1",
|
||||||
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
||||||
children=[title],
|
children=[title],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
|
def _table(rows: list, thead: Node | None = None) -> Node:
|
||||||
children = []
|
children = []
|
||||||
if thead is not None:
|
if thead is not None:
|
||||||
children.append(thead)
|
children.append(thead)
|
||||||
children.append(Component(tag_name="tbody", children=rows))
|
children.append(Element("tbody", children=rows))
|
||||||
return Component(
|
return Element(
|
||||||
tag_name="table",
|
"table",
|
||||||
attributes=[("class", "responsive-table")],
|
attributes=[("class", "responsive-table")],
|
||||||
children=children,
|
children=children,
|
||||||
)
|
)
|
||||||
@@ -63,7 +62,7 @@ def _dur(value) -> str:
|
|||||||
return format_duration(value, durationformat)
|
return format_duration(value, durationformat)
|
||||||
|
|
||||||
|
|
||||||
def _purchase_name(purchase) -> SafeText:
|
def _purchase_name(purchase) -> Node:
|
||||||
"""Mirror of the `purchase-name` partial in the old template."""
|
"""Mirror of the `purchase-name` partial in the old template."""
|
||||||
game_name = getattr(purchase, "game_name", None)
|
game_name = getattr(purchase, "game_name", None)
|
||||||
first_game = purchase.first_game
|
first_game = purchase.first_game
|
||||||
@@ -76,7 +75,7 @@ def _purchase_name(purchase) -> SafeText:
|
|||||||
return GameLink(first_game.id, name)
|
return GameLink(first_game.id, name)
|
||||||
|
|
||||||
|
|
||||||
def _year_nav(year, year_range, url_template) -> SafeText:
|
def _year_nav(year, year_range, url_template) -> Node:
|
||||||
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
|
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
|
||||||
# for the all-time view. Normalize to int-or-None so nothing downstream has
|
# for the all-time view. Normalize to int-or-None so nothing downstream has
|
||||||
# to know about the "Alltime" sentinel.
|
# to know about the "Alltime" sentinel.
|
||||||
@@ -107,7 +106,7 @@ def _year_nav(year, year_range, url_template) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _playtime_table(ctx) -> SafeText:
|
def _playtime_table(ctx) -> Node:
|
||||||
year = ctx.get("year")
|
year = ctx.get("year")
|
||||||
rows = [
|
rows = [
|
||||||
_kv("Hours", ctx.get("total_hours")),
|
_kv("Hours", ctx.get("total_hours")),
|
||||||
@@ -186,7 +185,7 @@ def _playtime_table(ctx) -> SafeText:
|
|||||||
return _table(rows)
|
return _table(rows)
|
||||||
|
|
||||||
|
|
||||||
def _purchases_table(ctx) -> SafeText:
|
def _purchases_table(ctx) -> Node:
|
||||||
rows = [
|
rows = [
|
||||||
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||||
_kv(
|
_kv(
|
||||||
@@ -213,18 +212,18 @@ def _purchases_table(ctx) -> SafeText:
|
|||||||
return _table(rows)
|
return _table(rows)
|
||||||
|
|
||||||
|
|
||||||
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
|
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
|
||||||
thead = Component(
|
thead = Element(
|
||||||
tag_name="thead",
|
"thead",
|
||||||
children=[_tr([_th(header), _th("Playtime")])],
|
children=[_tr([_th(header), _th("Playtime")])],
|
||||||
)
|
)
|
||||||
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
||||||
return _table(rows, thead)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def _finished_table(purchases) -> SafeText:
|
def _finished_table(purchases) -> Node:
|
||||||
thead = Component(
|
thead = Element(
|
||||||
tag_name="thead",
|
"thead",
|
||||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||||
)
|
)
|
||||||
rows = [
|
rows = [
|
||||||
@@ -234,9 +233,9 @@ def _finished_table(purchases) -> SafeText:
|
|||||||
return _table(rows, thead)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def _priced_table(purchases, currency) -> SafeText:
|
def _priced_table(purchases, currency) -> Node:
|
||||||
thead = Component(
|
thead = Element(
|
||||||
tag_name="thead",
|
"thead",
|
||||||
children=[
|
children=[
|
||||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||||
],
|
],
|
||||||
@@ -254,7 +253,7 @@ def _priced_table(purchases, currency) -> SafeText:
|
|||||||
return _table(rows, thead)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def stats_content(ctx: dict) -> SafeText:
|
def stats_content(ctx: dict) -> Node:
|
||||||
year = ctx.get("year")
|
year = ctx.get("year")
|
||||||
currency = ctx.get("total_spent_currency")
|
currency = ctx.get("total_spent_currency")
|
||||||
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ from common.components import (
|
|||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
Component,
|
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
|
Element,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import P
|
from common.components.primitives import P
|
||||||
@@ -89,8 +89,8 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
form = Component(
|
form = Element(
|
||||||
tag_name="form",
|
"form",
|
||||||
attributes=[("method", "post"), ("class", "dark:text-white")],
|
attributes=[("method", "post"), ("class", "dark:text-white")],
|
||||||
children=[CsrfInput(request), inner],
|
children=[CsrfInput(request), inner],
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-18
@@ -11,16 +11,16 @@ from games.models import Platform, Game, Purchase, Session
|
|||||||
# Component builders return lazy ``Node`` objects; these tests assert on rendered
|
# Component builders return lazy ``Node`` objects; these tests assert on rendered
|
||||||
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
|
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
|
||||||
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
|
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
|
||||||
# ``_resolve_name_with_icon``, the legacy string ``Component()``) are called
|
# ``_resolve_name_with_icon``, ``_render_element``) are called
|
||||||
# directly.
|
# directly.
|
||||||
|
|
||||||
|
|
||||||
class ComponentIntegrationTest(unittest.TestCase):
|
class ComponentIntegrationTest(unittest.TestCase):
|
||||||
"""Test Component() works correctly with caching transparent."""
|
"""Test Element() renders correctly with caching transparent."""
|
||||||
|
|
||||||
def test_tag_name_component(self):
|
def test_tag_name_component(self):
|
||||||
result = str(
|
result = str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", "test")],
|
attributes=[("class", "test")],
|
||||||
children="hello",
|
children="hello",
|
||||||
@@ -37,13 +37,13 @@ class ComponentCacheTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_identical_components_hit_cache(self):
|
def test_identical_components_hit_cache(self):
|
||||||
str(
|
str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div", attributes=[("class", "x")], children="hi"
|
tag_name="div", attributes=[("class", "x")], children="hi"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
misses = components._render_element.cache_info().misses
|
misses = components._render_element.cache_info().misses
|
||||||
str(
|
str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div", attributes=[("class", "x")], children="hi"
|
tag_name="div", attributes=[("class", "x")], children="hi"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -58,9 +58,9 @@ class ComponentCacheTest(unittest.TestCase):
|
|||||||
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
|
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
|
||||||
render differently — the cache key must keep them distinct."""
|
render differently — the cache key must keep them distinct."""
|
||||||
safe = str(
|
safe = str(
|
||||||
components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
|
components.Element(tag_name="span", children=[mark_safe("<b>x</b>")])
|
||||||
)
|
)
|
||||||
unsafe = str(components.Component(tag_name="span", children=["<b>x</b>"]))
|
unsafe = str(components.Element(tag_name="span", children=["<b>x</b>"]))
|
||||||
self.assertIn("<b>x</b>", safe)
|
self.assertIn("<b>x</b>", safe)
|
||||||
self.assertIn("<b>x</b>", unsafe)
|
self.assertIn("<b>x</b>", unsafe)
|
||||||
self.assertNotEqual(safe, unsafe)
|
self.assertNotEqual(safe, unsafe)
|
||||||
@@ -324,26 +324,26 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ComponentEdgeCasesTest(unittest.TestCase):
|
class ComponentEdgeCasesTest(unittest.TestCase):
|
||||||
"""Test Component() edge cases and error handling."""
|
"""Test Element() edge cases and error handling."""
|
||||||
|
|
||||||
def test_no_tag_name_raises(self):
|
def test_no_tag_name_raises(self):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
str(components.Component(children="hello"))
|
str(components.Element("", children="hello"))
|
||||||
self.assertIn("tag_name", str(ctx.exception))
|
self.assertIn("tag_name", str(ctx.exception))
|
||||||
|
|
||||||
def test_single_string_children_wrapped(self):
|
def test_single_string_children_wrapped(self):
|
||||||
result = str(components.Component(tag_name="span", children="hello"))
|
result = str(components.Element(tag_name="span", children="hello"))
|
||||||
self.assertIn("hello", result)
|
self.assertIn("hello", result)
|
||||||
|
|
||||||
def test_multiple_children_joined_with_newlines(self):
|
def test_multiple_children_joined_with_newlines(self):
|
||||||
result = str(components.Component(tag_name="div", children=["hello", "world"]))
|
result = str(components.Element(tag_name="div", children=["hello", "world"]))
|
||||||
self.assertIn("hello\nworld", result)
|
self.assertIn("hello\nworld", result)
|
||||||
self.assertIn("<div>", result)
|
self.assertIn("<div>", result)
|
||||||
self.assertIn("</div>", result)
|
self.assertIn("</div>", result)
|
||||||
|
|
||||||
def test_raw_html_children_are_escaped(self):
|
def test_raw_html_children_are_escaped(self):
|
||||||
result = str(
|
result = str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div", children=["<script>alert('xss')</script>"]
|
tag_name="div", children=["<script>alert('xss')</script>"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -352,7 +352,7 @@ class ComponentEdgeCasesTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_mark_safe_children_pass_through(self):
|
def test_mark_safe_children_pass_through(self):
|
||||||
result = str(
|
result = str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -360,7 +360,7 @@ class ComponentEdgeCasesTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_attribute_values_are_escaped(self):
|
def test_attribute_values_are_escaped(self):
|
||||||
result = str(
|
result = str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("data-x", 'foo"bar')],
|
attributes=[("data-x", 'foo"bar')],
|
||||||
)
|
)
|
||||||
@@ -370,7 +370,7 @@ class ComponentEdgeCasesTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_attributes_serialized_correctly(self):
|
def test_attributes_serialized_correctly(self):
|
||||||
result = str(
|
result = str(
|
||||||
components.Component(
|
components.Element(
|
||||||
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -378,17 +378,17 @@ class ComponentEdgeCasesTest(unittest.TestCase):
|
|||||||
self.assertIn('id="bar"', result)
|
self.assertIn('id="bar"', result)
|
||||||
|
|
||||||
def test_empty_attributes_no_extra_space(self):
|
def test_empty_attributes_no_extra_space(self):
|
||||||
result = str(components.Component(tag_name="span", children="x"))
|
result = str(components.Element(tag_name="span", children="x"))
|
||||||
self.assertEqual(result, "<span>x</span>")
|
self.assertEqual(result, "<span>x</span>")
|
||||||
self.assertNotIn(" <span", result)
|
self.assertNotIn(" <span", result)
|
||||||
|
|
||||||
def test_non_string_children_not_supported(self):
|
def test_non_string_children_not_supported(self):
|
||||||
"""Component only accepts str for children, not integers."""
|
"""Component only accepts str for children, not integers."""
|
||||||
result = str(components.Component(tag_name="span", children=str(42)))
|
result = str(components.Element(tag_name="span", children=str(42)))
|
||||||
self.assertIn("42", result)
|
self.assertIn("42", result)
|
||||||
|
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
result = str(components.Component(tag_name="div", children="test"))
|
result = str(components.Element(tag_name="div", children="test"))
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-18
@@ -1,17 +1,14 @@
|
|||||||
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
|
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
|
||||||
|
|
||||||
These cover the new machinery directly and assert byte-for-byte parity between
|
These cover the new machinery directly: rendering, escaping, media bubbling.
|
||||||
``Element`` and the legacy ``Component()`` shim, so the migration of call sites
|
|
||||||
in later phases can rely on identical output.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
BaseComponent,
|
BaseComponent,
|
||||||
Component,
|
|
||||||
Element,
|
Element,
|
||||||
Fragment,
|
Fragment,
|
||||||
Media,
|
Media,
|
||||||
@@ -23,12 +20,8 @@ from common.components import (
|
|||||||
|
|
||||||
|
|
||||||
class ElementRenderTest(unittest.TestCase):
|
class ElementRenderTest(unittest.TestCase):
|
||||||
def test_matches_legacy_component(self):
|
def test_renders_tag_attrs_children(self):
|
||||||
element = Element("div", [("class", "test")], "hello")
|
element = Element("div", [("class", "test")], "hello")
|
||||||
legacy = Component(
|
|
||||||
tag_name="div", attributes=[("class", "test")], children="hello"
|
|
||||||
)
|
|
||||||
self.assertEqual(render(element), legacy)
|
|
||||||
self.assertEqual(render(element), '<div class="test">hello</div>')
|
self.assertEqual(render(element), '<div class="test">hello</div>')
|
||||||
|
|
||||||
def test_plain_string_children_escaped(self):
|
def test_plain_string_children_escaped(self):
|
||||||
@@ -48,14 +41,6 @@ class ElementRenderTest(unittest.TestCase):
|
|||||||
render(Element("span", children=[inner])), "<span><b>x</b></span>"
|
render(Element("span", children=[inner])), "<span><b>x</b></span>"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_legacy_component_renders_node_children(self):
|
|
||||||
# The compatibility bridge: a string-returning legacy Component must
|
|
||||||
# render nested Node children as HTML, not escape them.
|
|
||||||
inner = Element("b", children=["x"])
|
|
||||||
result = Component(tag_name="span", children=[inner])
|
|
||||||
self.assertEqual(result, "<span><b>x</b></span>")
|
|
||||||
self.assertIsInstance(result, SafeText)
|
|
||||||
|
|
||||||
|
|
||||||
class SafeAndFragmentTest(unittest.TestCase):
|
class SafeAndFragmentTest(unittest.TestCase):
|
||||||
def test_safe_passes_html_through(self):
|
def test_safe_passes_html_through(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user