diff --git a/CLAUDE.md b/CLAUDE.md index 443180e..4a9046a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`: -- **`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()`). - **`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) @@ -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. - **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. -- **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. - **Filter views** accept `?filter=` (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. diff --git a/common/components/__init__.py b/common/components/__init__.py index da49499..5072ee1 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -6,7 +6,6 @@ re-exports the public API so ``from common.components import X`` keeps working. from common.components.core import ( BaseComponent, - Component, Element, Fragment, HTMLAttribute, @@ -92,7 +91,6 @@ from common.utils import truncate __all__ = [ "truncate", "BaseComponent", - "Component", "Element", "Fragment", "Media", diff --git a/common/components/core.py b/common/components/core.py index 2c868af..8088355 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -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 ``Page()`` walk a finished tree and collect every component's declared JS (: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 @@ -288,20 +283,6 @@ def collect_media(node: "Node | str") -> 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: if not seed and not content: return seed diff --git a/games/views/auth.py b/games/views/auth.py index 7ee87f5..bd7583d 100644 --- a/games/views/auth.py +++ b/games/views/auth.py @@ -3,16 +3,16 @@ registration/login.html).""" from django.contrib.auth import views as auth_views 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.layout import render_page -def _login_content(form, request) -> SafeText: - table = Component( - tag_name="table", +def _login_content(form, request) -> Node: + table = Element( + "table", children=[ CsrfInput(request), mark_safe(str(form.as_table())), @@ -31,13 +31,13 @@ def _login_content(form, request) -> SafeText: return Div( [("class", "flex items-center flex-col")], [ - Component( - tag_name="h2", + Element( + "h2", attributes=[("class", "text-3xl text-white mb-8")], children=["Please log in to continue"], ), - Component( - tag_name="form", + Element( + "form", attributes=[("method", "post")], children=[table], ), diff --git a/games/views/game.py b/games/views/game.py index 4378988..ed3c6e7 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -17,9 +17,9 @@ from common.components import ( AddForm, Button, ButtonGroup, - Component, CsrfInput, Div, + Element, FilterBar, GameStatus, GameStatusSelector, @@ -201,8 +201,8 @@ def _delete_game_confirmation_modal( if not (session_count or purchase_count or playevent_count): data_items.append(Li(children=["No associated data"])) - form = Component( - tag_name="form", + form = Element( + "form", attributes=[ ("hx-post", reverse("games:delete_game", args=[game.id])), ("hx-replace-url", "true"), @@ -442,8 +442,8 @@ def _game_action_buttons(game: Game) -> SafeText: edit_link = A( href=reverse("games:edit_game", args=[game.id]), children=[ - Component( - tag_name="button", + Element( + "button", attributes=[("type", "button"), ("class", edit_class)], children=["Edit"], ) @@ -456,8 +456,8 @@ def _game_action_buttons(game: Game) -> SafeText: ("hx-target", "#global-modal-container"), ], children=[ - Component( - tag_name="button", + Element( + "button", attributes=[("type", "button"), ("class", delete_class)], children=["Delete"], ) diff --git a/games/views/purchase.py b/games/views/purchase.py index 12f3d8a..2f4d0db 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -19,14 +19,15 @@ from common.components import ( AddForm, Button, ButtonGroup, - Component, CsrfInput, Div, + Element, GameLink, Icon, LinkedPurchase, Modal, ModuleScript, + Node, PriceConverted, PurchasePrice, TableRow, @@ -301,9 +302,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: return redirect("games:list_purchases") -def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText: - form = Component( - tag_name="form", +def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node: + form = Element( + "form", attributes=[ ("hx-post", reverse("games:refund_purchase", args=[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( "refund-confirmation-modal", children=[ - Component( - tag_name="h1", + Element( + "h1", attributes=[ ( "class", diff --git a/games/views/stats_content.py b/games/views/stats_content.py index 8d6063a..0b0f130 100644 --- a/games/views/stats_content.py +++ b/games/views/stats_content.py @@ -9,9 +9,9 @@ from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import floatformat from django.urls import reverse 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 _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" -def _td(children, cls: str = _CELL_MONO) -> SafeText: +def _td(children, cls: str = _CELL_MONO) -> Node: if not isinstance(children, list): children = [children] - children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children] - return Component(tag_name="td", attributes=[("class", cls)], children=children) + return Td(attributes=[("class", cls)], children=children) -def _th(text: str, cls: str = _CELL) -> SafeText: - return Component(tag_name="th", attributes=[("class", cls)], children=[text]) +def _th(text: str, cls: str = _CELL) -> Node: + return Th(attributes=[("class", cls)], children=[text]) -def _tr(cells: list) -> SafeText: - return Component(tag_name="tr", children=cells) +def _tr(cells: list) -> Node: + return Tr(children=cells) -def _kv(label, value) -> SafeText: +def _kv(label, value) -> Node: """A label/value row: plain label cell + mono value cell.""" return _tr([_td(label, _CELL), _td(value)]) -def _h1(title: str) -> SafeText: - return Component( - tag_name="h1", +def _h1(title: str) -> Node: + return Element( + "h1", attributes=[("class", "text-3xl text-heading text-center my-6")], children=[title], ) -def _table(rows: list, thead: SafeText | None = None) -> SafeText: +def _table(rows: list, thead: Node | None = None) -> Node: children = [] if thead is not None: children.append(thead) - children.append(Component(tag_name="tbody", children=rows)) - return Component( - tag_name="table", + children.append(Element("tbody", children=rows)) + return Element( + "table", attributes=[("class", "responsive-table")], children=children, ) @@ -63,7 +62,7 @@ def _dur(value) -> str: 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.""" game_name = getattr(purchase, "game_name", None) first_game = purchase.first_game @@ -76,7 +75,7 @@ def _purchase_name(purchase) -> SafeText: 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) # for the all-time view. Normalize to int-or-None so nothing downstream has # 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") rows = [ _kv("Hours", ctx.get("total_hours")), @@ -186,7 +185,7 @@ def _playtime_table(ctx) -> SafeText: return _table(rows) -def _purchases_table(ctx) -> SafeText: +def _purchases_table(ctx) -> Node: rows = [ _kv("Total", ctx.get("all_purchased_this_year_count")), _kv( @@ -213,18 +212,18 @@ def _purchases_table(ctx) -> SafeText: return _table(rows) -def _two_col_table(header: str, items, name_key, value_fn) -> SafeText: - thead = Component( - tag_name="thead", +def _two_col_table(header: str, items, name_key, value_fn) -> Node: + thead = Element( + "thead", children=[_tr([_th(header), _th("Playtime")])], ) rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items] return _table(rows, thead) -def _finished_table(purchases) -> SafeText: - thead = Component( - tag_name="thead", +def _finished_table(purchases) -> Node: + thead = Element( + "thead", children=[_tr([_th("Name", _NAME_TH), _th("Date")])], ) rows = [ @@ -234,9 +233,9 @@ def _finished_table(purchases) -> SafeText: return _table(rows, thead) -def _priced_table(purchases, currency) -> SafeText: - thead = Component( - tag_name="thead", +def _priced_table(purchases, currency) -> Node: + thead = Element( + "thead", children=[ _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) -def stats_content(ctx: dict) -> SafeText: +def stats_content(ctx: dict) -> Node: year = ctx.get("year") currency = ctx.get("total_spent_currency") # Build a navigation URL with an `__year__` placeholder the picker's JS diff --git a/games/views/statuschange.py b/games/views/statuschange.py index 994d49f..651cc1f 100644 --- a/games/views/statuschange.py +++ b/games/views/statuschange.py @@ -8,9 +8,9 @@ from common.components import ( A, AddForm, Button, - Component, CsrfInput, Div, + Element, paginated_table_content, ) from common.components.primitives import P @@ -89,8 +89,8 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText ), ], ) - form = Component( - tag_name="form", + form = Element( + "form", attributes=[("method", "post"), ("class", "dark:text-white")], children=[CsrfInput(request), inner], ) diff --git a/tests/test_components.py b/tests/test_components.py index 38f2398..682558c 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -11,16 +11,16 @@ from games.models import Platform, Game, Purchase, Session # Component builders return lazy ``Node`` objects; these tests assert on rendered # HTML, so node-returning calls are wrapped in ``str(...)`` at the call site # (``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. class ComponentIntegrationTest(unittest.TestCase): - """Test Component() works correctly with caching transparent.""" + """Test Element() renders correctly with caching transparent.""" def test_tag_name_component(self): result = str( - components.Component( + components.Element( tag_name="div", attributes=[("class", "test")], children="hello", @@ -37,13 +37,13 @@ class ComponentCacheTest(unittest.TestCase): def test_identical_components_hit_cache(self): str( - components.Component( + components.Element( tag_name="div", attributes=[("class", "x")], children="hi" ) ) misses = components._render_element.cache_info().misses str( - components.Component( + components.Element( tag_name="div", attributes=[("class", "x")], children="hi" ) ) @@ -58,9 +58,9 @@ class ComponentCacheTest(unittest.TestCase): """A SafeText "" and a plain "" are equal as strings but must render differently — the cache key must keep them distinct.""" safe = str( - components.Component(tag_name="span", children=[mark_safe("x")]) + components.Element(tag_name="span", children=[mark_safe("x")]) ) - unsafe = str(components.Component(tag_name="span", children=["x"])) + unsafe = str(components.Element(tag_name="span", children=["x"])) self.assertIn("x", safe) self.assertIn("<b>x</b>", unsafe) self.assertNotEqual(safe, unsafe) @@ -324,26 +324,26 @@ class ComponentOutputIsNotEscapedTest(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): with self.assertRaises(ValueError) as ctx: - str(components.Component(children="hello")) + str(components.Element("", children="hello")) self.assertIn("tag_name", str(ctx.exception)) 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) 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("
", result) self.assertIn("
", result) def test_raw_html_children_are_escaped(self): result = str( - components.Component( + components.Element( tag_name="div", children=[""] ) ) @@ -352,7 +352,7 @@ class ComponentEdgeCasesTest(unittest.TestCase): def test_mark_safe_children_pass_through(self): result = str( - components.Component( + components.Element( tag_name="div", children=[mark_safe("safe")] ) ) @@ -360,7 +360,7 @@ class ComponentEdgeCasesTest(unittest.TestCase): def test_attribute_values_are_escaped(self): result = str( - components.Component( + components.Element( tag_name="div", attributes=[("data-x", 'foo"bar')], ) @@ -370,7 +370,7 @@ class ComponentEdgeCasesTest(unittest.TestCase): def test_attributes_serialized_correctly(self): result = str( - components.Component( + components.Element( tag_name="div", attributes=[("class", "foo"), ("id", "bar")] ) ) @@ -378,17 +378,17 @@ class ComponentEdgeCasesTest(unittest.TestCase): self.assertIn('id="bar"', result) 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, "x") self.assertNotIn(" hello') def test_plain_string_children_escaped(self): @@ -48,14 +41,6 @@ class ElementRenderTest(unittest.TestCase): render(Element("span", children=[inner])), "x" ) - 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, "x") - self.assertIsInstance(result, SafeText) - class SafeAndFragmentTest(unittest.TestCase): def test_safe_passes_html_through(self):