Merge pull request #15 from KucharczykL/claude/kind-gauss-vj2wyp
Django CI/CD / test (push) Successful in 3m9s
Django CI/CD / build-and-push (push) Successful in 1m44s

Lazy node-tree component system + onSwap widget lifecycle
This commit is contained in:
2026-06-13 20:58:02 +02:00
committed by GitHub
45 changed files with 6117 additions and 1113 deletions
+15 -9
View File
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
common/ — Shared utilities: time formatting, component system, criteria, layout, icons common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests tests/ — Pytest tests
e2e/ — Playwright browser tests (run via `make test-e2e`)
contrib/ — One-off scripts (exchange rate import) contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation docs/ — Additional documentation
``` ```
@@ -57,12 +58,12 @@ docs/ — Additional documentation
### Key patterns ### Key patterns
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor. **Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
**Component system** (`common/components/`): Pure-Python HTML builders, split into four 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`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `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: every string child is escaped — `SafeText`/`mark_safe` included; only `Node` children (so `Safe`) render unescaped.** Trusted pre-rendered HTML must be wrapped in `Safe(...)`, never passed as a safe string. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()` - **`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)
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js` - **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
@@ -113,13 +114,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
### Frontend stack ### Frontend stack
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates - **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store - **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (CDN) — navbar collapse, dropdown toggles - **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
- **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css` - **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
- **Custom JS** in `games/static/js/`: - **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event) - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode) - `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`) - `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
### Deployment ### Deployment
@@ -155,13 +158,16 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`). Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
## Conventions for AI assistants ## Conventions for AI assistants
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database. - **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
- **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.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag. - **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=<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.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
+24 -2
View File
@@ -5,11 +5,18 @@ re-exports the public API so ``from common.components import X`` keeps working.
""" """
from common.components.core import ( from common.components.core import (
Component, BaseComponent,
Element,
Fragment,
HTMLAttribute, HTMLAttribute,
HTMLTag, HTMLTag,
Media,
Node,
Safe,
_render_element, _render_element,
collect_media,
randomid, randomid,
render,
) )
from common.components.date_range_picker import ( from common.components.date_range_picker import (
DateRangeCalendar, DateRangeCalendar,
@@ -59,11 +66,13 @@ from common.components.primitives import (
SearchField, SearchField,
SimpleTable, SimpleTable,
Span, Span,
StaticScript,
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
Td, Td,
Template, Template,
Th,
Tr, Tr,
Ul, Ul,
YearPicker, YearPicker,
@@ -81,7 +90,14 @@ from common.utils import truncate
__all__ = [ __all__ = [
"truncate", "truncate",
"Component", "BaseComponent",
"Element",
"Fragment",
"Media",
"Node",
"Safe",
"collect_media",
"render",
"HTMLAttribute", "HTMLAttribute",
"HTMLTag", "HTMLTag",
"_render_element", "_render_element",
@@ -112,7 +128,13 @@ __all__ = [
"searchselect_selected", "searchselect_selected",
"SimpleTable", "SimpleTable",
"Span", "Span",
"StaticScript",
"Label", "Label",
"Li",
"Td",
"Th",
"Tr",
"Ul",
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
+293 -24
View File
@@ -1,6 +1,20 @@
"""Escaping core: the Component builder and its memoised renderer.""" """Node layer: the lazy component tree, its renderer, and media collection.
A FastHTML-style model. Everything renderable is a :class:`Node`. The single
:class:`Element` class represents *any* HTML element (tag + attrs + children);
named builders like ``Div`` / ``Span`` are generated from a whitelist rather
than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or
media-bearing components subclass :class:`BaseComponent` and implement
``render()`` returning a node subtree.
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.
"""
import hashlib import hashlib
from collections.abc import Sequence
from functools import lru_cache from functools import lru_cache
from django.utils.html import escape from django.utils.html import escape
@@ -10,24 +24,181 @@ from django.utils.safestring import SafeText, mark_safe
HTMLAttribute = tuple[str, str | int | bool] HTMLAttribute = tuple[str, str | int | bool]
# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a
# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]``
# would be invariant and reject it). Locals that get ``.append()``-ed should
# stay a concrete ``list[HTMLAttribute]``.
Attributes = Sequence[HTMLAttribute]
HTMLTag = str HTMLTag = str
# ── Media: declarative JS dependencies ──────────────────────────────────────
def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]:
"""First-seen dedup that preserves declaration order across sequences."""
seen: dict[str, None] = {}
for sequence in sequences:
for item in sequence:
seen.setdefault(item, None)
return tuple(seen)
class Media:
"""A component's JS dependencies, modelled on ``django.forms.Media``.
``js`` are static ES-module filenames (rendered as ``ModuleScript``);
``js_external`` are vendored UMD / classic bundles (rendered as
``StaticScript``). Addition merges with first-seen, order-preserving dedup,
so a page that uses a component many times emits each script once.
"""
__slots__ = ("js", "js_external")
def __init__(
self,
js: tuple[str, ...] | list[str] = (),
js_external: tuple[str, ...] | list[str] = (),
) -> None:
self.js = tuple(js)
self.js_external = tuple(js_external)
def __add__(self, other: "Media | None") -> "Media":
if not other:
return self
return Media(
_dedup(self.js, other.js),
_dedup(self.js_external, other.js_external),
)
def __radd__(self, other: "Media | None") -> "Media":
# Supports ``sum(medias, Media())`` and ``0 + media``.
if not other or other == 0:
return self
return other.__add__(self)
def __bool__(self) -> bool:
return bool(self.js or self.js_external)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Media)
and self.js == other.js
and self.js_external == other.js_external
)
def __hash__(self) -> int:
return hash((self.js, self.js_external))
def __repr__(self) -> str:
return f"Media(js={self.js!r}, js_external={self.js_external!r})"
# ── Node tree ────────────────────────────────────────────────────────────────
class Node:
"""Base class for everything renderable to HTML."""
# Declared dependencies. Class-level default is shared and empty; concrete
# components override with their own ``Media(...)``.
media: Media = Media()
def _render(self) -> str:
raise NotImplementedError
def collect_media(self) -> Media:
"""Total media of this node and its subtree."""
return self.media
def with_media(self, media: Media) -> "Node":
"""Attach JS dependencies to this node and return it (for fluent use).
Lets a function-built node declare its media without becoming a full
``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
"""
self.media = self.media + media
return self
# A node's rendered output is always safe HTML by construction (Element
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
# strings). So both `__html__` (Django's conditional_escape hook) and
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
# fed back into a child list or template, matching the old SafeText shims.
def __html__(self) -> SafeText:
return mark_safe(self._render())
def __str__(self) -> SafeText:
return mark_safe(self._render())
# A renderable child is a node or a string. Strings are ALWAYS escaped (a string
# is untrusted text — ``SafeText``/``mark_safe`` is escaped too); trusted
# pre-rendered HTML must be a ``Safe`` node. ``Children`` is the type for a
# builder's ``children``
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
# higher-level builders take ``Children``.
Child = Node | str
Children = Sequence[Child] | Node | str | None
def as_children(children: Children) -> list[Child]:
"""Normalise a builder's ``children`` argument to a flat list.
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
sequence of them. Lets builders drop the ``children if isinstance(children,
list) else [children]`` dance and get a properly typed ``list[Child]``.
"""
if children is None:
return []
if isinstance(children, (str, Node)):
return [children]
return list(children)
def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
"""Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``.
Builders take a covariant ``Attributes`` (so callers can pass a
``list[tuple[str, str]]``) but often append to or concatenate the value;
this turns it into a concrete list they can mutate.
"""
return list(attributes) if attributes else []
def _child_key(child: object) -> tuple[str, bool]:
"""Normalise a child to a ``(text, is_safe)`` pair.
Only :class:`Node` children render unescaped — that includes :class:`Safe`,
the one sanctioned way to put trusted pre-rendered HTML into the tree. Every
*string* child is escaped, ``SafeText``/``mark_safe`` included: a string is
always treated as untrusted text, so trusted markup must be wrapped in
``Safe(...)`` rather than smuggled in as a safe string. ``is_safe`` is part
of the render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never
collide.
"""
if isinstance(child, Node):
return (child._render(), True)
if isinstance(child, str):
return (child, False)
return (str(child), False)
@lru_cache(maxsize=4096) @lru_cache(maxsize=4096)
def _render_element( def _render_element(
tag_name: str, tag_name: str,
attrs_key: tuple[tuple[str, str], ...], attrs_key: tuple[tuple[str, str], ...],
children_key: tuple[tuple[str, bool], ...], children_key: tuple[tuple[str, bool], ...],
) -> str: ) -> str:
"""Pure, memoized HTML builder behind `Component`. """Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
Inputs are fully hashable and fully determine the output, so identical ``attrs_key`` is (name, stringified value) pairs (values always escaped);
elements are rendered once. `attrs_key` is (name, stringified value) pairs ``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
(attribute values are always escaped). `children_key` is (child, is_safe)
pairs: SafeText children pass through, plain strings are escaped. The
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
render with the wrong escaping.
""" """
children_blob = "\n".join( children_blob = "\n".join(
child if is_safe else escape(child) for child, is_safe in children_key child if is_safe else escape(child) for child, is_safe in children_key
@@ -41,24 +212,122 @@ def _render_element(
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>" return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
def Component( class Element(Node):
attributes: list[HTMLAttribute] | None = None, """Any HTML element: a tag name, attributes and children.
children: list[HTMLTag] | HTMLTag | None = None,
tag_name: str = "", Children may be other nodes, ``SafeText``, or plain strings (escaped).
) -> SafeText: Rendering goes through the memoized :func:`_render_element`.
"""Render an HTML element. Attribute values are always escaped; children are """
escaped unless they are `SafeText` (so nested components pass through),
preventing accidental HTML injection. Rendering is memoized via def __init__(
`_render_element`.""" self,
attributes = attributes or [] tag_name: str,
children = children or [] attributes: Attributes | None = None,
children: "Children | Node" = None,
) -> None:
if not tag_name: if not tag_name:
raise ValueError("tag_name is required.") raise ValueError("tag_name is required.")
if isinstance(children, str): self.tag_name = tag_name
self.attributes = attributes or []
if children is None:
children = []
elif isinstance(children, (str, Node)):
children = [children] children = [children]
attrs_key = tuple((name, str(value)) for name, value in attributes) self.children = children
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
return mark_safe(_render_element(tag_name, attrs_key, children_key)) def collect_media(self) -> Media:
media = self.media
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
attrs_key = tuple((name, str(value)) for name, value in self.attributes)
children_key = tuple(_child_key(child) for child in self.children)
return _render_element(self.tag_name, attrs_key, children_key)
class Safe(Node):
"""A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue).
Used as the migration bridge for components still built from f-strings:
they return ``Safe(html)`` and declare their ``media`` explicitly rather
than atomising their markup into a node tree up front.
"""
def __init__(self, html: object, media: Media | None = None) -> None:
self._html = str(html)
if media is not None:
self.media = media
def _render(self) -> str:
return self._html
class Fragment(Node):
"""An ordered group of children with no wrapping tag.
Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition,
so media still bubbles up from the grouped children.
"""
def __init__(self, *children: object, separator: str = "") -> None:
self.children = [c for c in children if c is not None and c != ""]
self.separator = separator
def collect_media(self) -> Media:
media = Media()
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
parts = []
for child in self.children:
text, is_safe = _child_key(child)
parts.append(text if is_safe else escape(text))
return self.separator.join(parts)
class BaseComponent(Node):
"""Base for higher-level components: implement ``render()`` returning a node
subtree and declare ``media`` (a :class:`Media`).
``render()`` is called once and memoized; ``collect_media()`` returns this
component's own media merged with the rendered subtree's.
"""
def render(self) -> Node:
raise NotImplementedError
def _tree(self) -> Node:
cached = getattr(self, "_tree_cache", None)
if cached is None:
cached = self.render()
self._tree_cache = cached
return cached
def _render(self) -> str:
return self._tree()._render()
def collect_media(self) -> Media:
return self.media + self._tree().collect_media()
def render(node: "Node | str") -> SafeText:
"""Render a node (or pass a string through) to safe HTML."""
if isinstance(node, Node):
return mark_safe(node._render())
return mark_safe(str(node))
def collect_media(node: "Node | str") -> Media:
"""Collect the media of a node tree (empty for a bare string)."""
if isinstance(node, Node):
return node.collect_media()
return Media()
def randomid(seed: str = "", content: str = "", length: int = 10) -> str: def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
+22 -20
View File
@@ -17,12 +17,14 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``games/static/js/date_range_picker.js``. ``games/static/js/date_range_picker.js``.
""" """
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLAttribute from common.components.core import Element, HTMLAttribute, Media, Node, Safe
from common.components.primitives import Div, Input, Span from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts from common.time import DatePartSpec, date_parts
# Wired by date_range_picker.js.
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
_FIELD_CONTAINER_CLASS = ( _FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium " "flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text " "bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
@@ -101,7 +103,7 @@ def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str
def _segment_input( def _segment_input(
*, part: DatePartSpec, side: str, label: str, value: str *, part: DatePartSpec, side: str, label: str, value: str
) -> SafeText: ) -> Node:
side_label = "from" if side == "min" else "to" side_label = "from" if side == "min" else "to"
return Input( return Input(
attributes=[ attributes=[
@@ -122,11 +124,11 @@ def _segment_input(
) )
def _segment_group(*, side: str, label: str, iso_value: str) -> SafeText: def _segment_group(*, side: str, label: str, iso_value: str) -> Node:
"""One date's worth of segments (``DD - MM - YYYY``) for a range side.""" """One date's worth of segments (``DD - MM - YYYY``) for a range side."""
parts = date_parts() parts = date_parts()
initial_values = _iso_part_values(iso_value, parts) initial_values = _iso_part_values(iso_value, parts)
children: list[SafeText] = [] children: list[Node] = []
for index, part in enumerate(parts): for index, part in enumerate(parts):
if index > 0: if index > 0:
children.append( children.append(
@@ -158,7 +160,7 @@ def DateRangeField(
input_name_prefix: str, input_name_prefix: str,
min_value: str = "", min_value: str = "",
max_value: str = "", max_value: str = "",
) -> SafeText: ) -> Node:
"""The visible half of the DateRangePicker: a single-input-looking """The visible half of the DateRangePicker: a single-input-looking
container holding two segmented dates, a calendar toggle, and the two container holding two segmented dates, a calendar toggle, and the two
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
@@ -195,8 +197,8 @@ def DateRangeField(
children=[""], children=[""],
), ),
_segment_group(side="max", label=label, iso_value=max_value), _segment_group(side="max", label=label, iso_value=max_value),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-date-range-calendar-toggle", ""), ("data-date-range-calendar-toggle", ""),
@@ -207,15 +209,15 @@ def DateRangeField(
"cursor-pointer shrink-0", "cursor-pointer shrink-0",
), ),
], ],
children=[mark_safe(_CALENDAR_ICON_SVG)], children=[Safe(_CALENDAR_ICON_SVG)],
), ),
], ],
) )
def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText: def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
(f"data-date-range-{direction}", ""), (f"data-date-range-{direction}", ""),
@@ -226,9 +228,9 @@ def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
) )
def _footer_button(action: str, label: str, button_class: str) -> SafeText: def _footer_button(action: str, label: str, button_class: str) -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
(f"data-date-range-{action}", ""), (f"data-date-range-{action}", ""),
@@ -238,13 +240,13 @@ def _footer_button(action: str, label: str, button_class: str) -> SafeText:
) )
def DateRangeCalendar(*, input_name_prefix: str) -> SafeText: def DateRangeCalendar(*, input_name_prefix: str) -> Node:
"""The popup half of the DateRangePicker: preset column, month grid """The popup half of the DateRangePicker: preset column, month grid
(filled client-side into ``[data-date-range-grid]``), and the (filled client-side into ``[data-date-range-grid]``), and the
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it.""" Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
preset_buttons = [ preset_buttons = [
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-date-range-preset", preset_value), ("data-date-range-preset", preset_value),
@@ -328,7 +330,7 @@ def DateRangePicker(
input_name_prefix: str, input_name_prefix: str,
min_value: str = "", min_value: str = "",
max_value: str = "", max_value: str = "",
) -> SafeText: ) -> Node:
"""A date-range widget: segmented manual entry plus a calendar popup. """A date-range widget: segmented manual entry plus a calendar popup.
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
@@ -352,4 +354,4 @@ def DateRangePicker(
), ),
DateRangeCalendar(input_name_prefix=input_name_prefix), DateRangeCalendar(input_name_prefix=input_name_prefix),
], ],
) ).with_media(_DATE_RANGE_MEDIA)
+19 -21
View File
@@ -4,9 +4,8 @@ from typing import Any
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.safestring import SafeText, mark_safe
from common.components.core import HTMLTag from common.components.core import Children, Node, Safe, as_children
from common.components.primitives import ( from common.components.primitives import (
A, A,
Div, Div,
@@ -21,13 +20,12 @@ from games.models import Game, Purchase, Session
def GameLink( def GameLink(
game_id: int, game_id: int,
name: str = "", name: str = "",
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> SafeText: ) -> Node:
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name.""" """Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
from django.urls import reverse from django.urls import reverse
children = children or [] display = as_children(children) or [name]
display = children if children else [name]
link = reverse("games:view_game", args=[game_id]) link = reverse("games:view_game", args=[game_id])
return Span( return Span(
@@ -38,7 +36,7 @@ def GameLink(
attributes=[ attributes=[
("class", "underline decoration-slate-500 sm:decoration-2"), ("class", "underline decoration-slate-500 sm:decoration-2"),
], ],
children=display if isinstance(display, list) else [display], children=display,
), ),
], ],
) )
@@ -54,11 +52,11 @@ _STATUS_COLORS = {
def GameStatus( def GameStatus(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
status: str = "u", status: str = "u",
display: str = "", display: str = "",
class_: str = "", class_: str = "",
) -> SafeText: ) -> Node:
"""Colored status dot with label. Status codes: u/p/f/a/r.""" """Colored status dot with label. Status codes: u/p/f/a/r."""
children = children or [] children = children or []
outer_class = ( outer_class = (
@@ -76,13 +74,13 @@ def GameStatus(
return Span( return Span(
attributes=[("class", outer_class)], attributes=[("class", outer_class)],
children=[dot] + (children if isinstance(children, list) else [children]), children=[dot] + as_children(children),
) )
def PriceConverted( def PriceConverted(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> SafeText: ) -> Node:
"""Wrap content in a span that indicates the price was converted.""" """Wrap content in a span that indicates the price was converted."""
children = children or [] children = children or []
return Span( return Span(
@@ -90,11 +88,11 @@ def PriceConverted(
("title", "Price is a result of conversion and rounding."), ("title", "Price is a result of conversion and rounding."),
("class", "decoration-dotted underline"), ("class", "decoration-dotted underline"),
], ],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )
def LinkedPurchase(purchase: Purchase) -> SafeText: def LinkedPurchase(purchase: Purchase) -> Node:
link = reverse("games:view_purchase", args=[int(purchase.id)]) link = reverse("games:view_purchase", args=[int(purchase.id)])
link_content = "" link_content = ""
popover_content = "" popover_content = ""
@@ -131,7 +129,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
), ),
PopoverTruncated( PopoverTruncated(
input_string=link_content, input_string=link_content,
popover_content=mark_safe(popover_content), popover_content=Safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated, popover_if_not_truncated=popover_if_not_truncated,
), ),
], ],
@@ -145,7 +143,7 @@ def NameWithIcon(
session: Session | None = None, session: Session | None = None,
linkify: bool = True, linkify: bool = True,
emulated: bool = False, emulated: bool = False,
) -> SafeText: ) -> Node:
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon( _name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
name, game, session, linkify name, game, session, linkify
) )
@@ -203,7 +201,7 @@ def _resolve_name_with_icon(
return _name, platform, final_emulated, create_link, link return _name, platform, final_emulated, create_link, link
def PurchasePrice(purchase) -> SafeText: def PurchasePrice(purchase) -> Node:
return Popover( return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}", popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}", wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
@@ -211,7 +209,7 @@ def PurchasePrice(purchase) -> SafeText:
) )
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText: def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a game's status.""" """Alpine.js dropdown to change a game's status."""
options_html = "\n".join( options_html = "\n".join(
f"<template x-if=\"status == '{value}'\">" f"<template x-if=\"status == '{value}'\">"
@@ -229,7 +227,7 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
for value, label in game_statuses for value, label in game_statuses
) )
return mark_safe(f""" return Safe(f"""
<div class="flex gap-2 items-center" <div class="flex gap-2 items-center"
x-data="{{ x-data="{{
status: '{game.status}', status: '{game.status}',
@@ -262,7 +260,7 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
""") """)
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText: def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a session's device.""" """Alpine.js dropdown to change a session's device."""
device_id = session.device_id or "null" device_id = session.device_id or "null"
device_name = (session.device.name if session.device else "Unknown").replace( device_name = (session.device.name if session.device else "Unknown").replace(
@@ -277,7 +275,7 @@ def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText
for d in session_devices for d in session_devices
) )
return mark_safe(f""" return Safe(f"""
<div class="flex gap-2 items-center" <div class="flex gap-2 items-center"
x-data="{{ x-data="{{
originalDeviceId: {device_id}, originalDeviceId: {device_id},
+156 -88
View File
@@ -3,9 +3,8 @@
from typing import NamedTuple from typing import NamedTuple
from django.db import models from django.db import models
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component from common.components.core import BaseComponent, Element, Media, Node, Safe
from common.components.date_range_picker import DateRangePicker from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import ( from common.components.search_select import (
@@ -53,6 +52,13 @@ _FILTER_RADIO_CLASS = (
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" _FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
# range_slider.js wires RangeSlider; filter_bar.js wires the bar chrome
# (Apply/Clear, presets, search injection). Widget media (search_select.js,
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
_RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",))
_FILTER_BAR_MEDIA = Media(js=("filter_bar.js",))
def _filter_parse(filter_json: str) -> dict: def _filter_parse(filter_json: str) -> dict:
if not filter_json: if not filter_json:
return {} return {}
@@ -169,7 +175,7 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
def _enum_filter( def _enum_filter(
field_name: str, options, choice: FilterChoice, *, nullable field_name: str, options, choice: FilterChoice, *, nullable
) -> SafeText: ) -> Node:
"""A FilterSelect over a small, fully pre-rendered option set (enum field). """A FilterSelect over a small, fully pre-rendered option set (enum field).
Enum fields are single-valued, so no M2M modifiers (all/only are Enum fields are single-valued, so no M2M modifiers (all/only are
@@ -200,7 +206,7 @@ def _model_filter(
search_url, search_url,
nullable, nullable,
m2m_modifiers: list[LabeledOption] | None = None, m2m_modifiers: list[LabeledOption] | None = None,
) -> SafeText: ) -> Node:
"""A FilterSelect backed by a search endpoint. """A FilterSelect backed by a search endpoint.
Labels are embedded in the filter JSON (Stash-style), so pills render Labels are embedded in the filter JSON (Stash-style), so pills render
@@ -233,34 +239,43 @@ def _filter_mins_to_hrs(val) -> str:
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}" return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
def _filter_field(label: str, widget, for_widget: str = None) -> SafeText: def _widget_id(widget) -> str:
"""A labelled filter field: <div><label>…</label>{widget}</div>. """Best-effort id of a widget node, for the field label's ``for`` target.
TODO: Use widget.attributes.get("id", "") to get the widget's ID
instead of the superfluous "for" argument. This requires refactoring Widgets are nodes carrying ``.attributes``, so the id is now reachable
the Component function to be a class intead. directly (the old free ``Component`` string couldn't expose it).
Also see RangeSlider's TODO
""" """
for name, value in getattr(widget, "attributes", []):
if name == "id":
return str(value)
return ""
def _filter_field(label: str, widget) -> Node:
"""A labelled filter field: ``<div><label>…</label>{widget}</div>``.
The label's ``for`` points at the widget's own id when it has one;
composite widgets without a single root id simply omit ``for``.
"""
label_attributes = [("class", _FILTER_LABEL_CLASS)]
widget_id = _widget_id(widget)
if widget_id:
label_attributes.append(("for", widget_id))
return Div( return Div(
attributes=[("class", "flex flex-col gap-1")], attributes=[("class", "flex flex-col gap-1")],
children=[ children=[
Label( Label(attributes=label_attributes, children=[label]),
attributes=[
("class", _FILTER_LABEL_CLASS),
("for", for_widget),
],
children=[label],
),
widget, widget,
], ],
) )
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: def _filter_checkbox(name: str, label: str, checked: bool) -> Node:
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive.""" """Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
return Checkbox(name=name, label=label, checked=checked) return Checkbox(name=name, label=label, checked=checked)
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText: def _filter_boolean_radio(name: str, label: str, value: bool | None) -> Node:
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options.""" """Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
return Div( return Div(
attributes=[("class", "flex flex-col gap-1")], attributes=[("class", "flex flex-col gap-1")],
@@ -314,7 +329,7 @@ def RangeSlider(
step: str = "1", step: str = "1",
min_placeholder: str = "", min_placeholder: str = "",
max_placeholder: str = "", max_placeholder: str = "",
) -> SafeText: ) -> Node:
"""A labelled range slider with number inputs and range/point mode toggle. """A labelled range slider with number inputs and range/point mode toggle.
Renders a label row (label, two number inputs, toggle button) and a slider Renders a label row (label, two number inputs, toggle button) and a slider
@@ -334,14 +349,9 @@ def RangeSlider(
Div( Div(
attributes=[("class", "flex items-center gap-2 mb-1")], attributes=[("class", "flex items-center gap-2 mb-1")],
children=[ children=[
# TODO: This should be done outside the RangeSlider component, but the current Component function doesn't allow getting the id # The field label is rendered by the _filter_field wrapper.
# Label( # This composite widget has no single labelable root, so the
# attributes=[ # label carries no `for` (the two inputs are named below).
# ("class", _FILTER_LABEL_CLASS),
# ("for", min_input_id),
# ],
# children=[label],
# ),
Input( Input(
attributes=[ attributes=[
("type", "number"), ("type", "number"),
@@ -376,8 +386,8 @@ def RangeSlider(
("class", _RANGE_SLIDER_INPUT_CLASS), ("class", _RANGE_SLIDER_INPUT_CLASS),
], ],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
( (
@@ -403,7 +413,7 @@ def RangeSlider(
+ (" hidden" if point_mode else ""), + (" hidden" if point_mode else ""),
), ),
], ],
children=[mark_safe(_RANGE_ICON_SVG)], children=[Safe(_RANGE_ICON_SVG)],
), ),
Span( Span(
attributes=[ attributes=[
@@ -413,7 +423,7 @@ def RangeSlider(
+ ("" if point_mode else " hidden"), + ("" if point_mode else " hidden"),
), ),
], ],
children=[mark_safe(_POINT_ICON_SVG)], children=[Safe(_POINT_ICON_SVG)],
), ),
], ],
), ),
@@ -482,7 +492,7 @@ def RangeSlider(
], ],
), ),
], ],
) ).with_media(_RANGE_SLIDER_MEDIA)
_DATE_RANGE_INPUT_CLASS = ( _DATE_RANGE_INPUT_CLASS = (
@@ -499,7 +509,7 @@ def DateRangeFilter(
max_value: str = "", max_value: str = "",
min_placeholder: str = "From", min_placeholder: str = "From",
max_placeholder: str = "To", max_placeholder: str = "To",
) -> SafeText: ) -> Node:
"""A pair of ``<input type="date">`` elements representing a date range. """A pair of ``<input type="date">`` elements representing a date range.
Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and
@@ -554,14 +564,16 @@ _FILTER_FORM_ID = "filter-bar-form"
_FILTER_INPUT_ID = "filter-json-input" _FILTER_INPUT_ID = "filter-json-input"
def _filter_collapse_button() -> SafeText: def _filter_collapse_button() -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
# Slider handles are positioned in percentages, so initializing
# them while the body is hidden is safe — no re-init on reveal.
( (
"onclick", "onclick",
"var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", "document.getElementById('filter-bar-body').classList.toggle('hidden')",
), ),
( (
"class", "class",
@@ -570,7 +582,7 @@ def _filter_collapse_button() -> SafeText:
), ),
], ],
children=[ children=[
mark_safe( Safe(
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>' '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'
), ),
"Filters", "Filters",
@@ -578,12 +590,12 @@ def _filter_collapse_button() -> SafeText:
) )
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
return Div( return Div(
attributes=[("class", "flex gap-3 items-center")], attributes=[("class", "flex gap-3 items-center")],
children=[ children=[
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "submit"), ("type", "submit"),
( (
@@ -595,8 +607,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
], ],
children=["Apply"], children=["Apply"],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
( (
@@ -632,8 +644,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
), ),
], ],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("id", "save-preset-btn"), ("id", "save-preset-btn"),
@@ -649,8 +661,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
], ],
children=["Save Preset"], children=["Save Preset"],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("id", "confirm-save-preset-btn"), ("id", "confirm-save-preset-btn"),
@@ -686,10 +698,36 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
) )
def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText: class _FilterBarBase(BaseComponent):
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body """Shared collapsible filter-bar chrome.
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
the hidden filter-json input and the Apply/Clear/preset action row.""" Subclasses implement ``build_fields()`` returning the per-entity body
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
the form, the hidden filter-json input and the Apply/Clear/preset action
row. ``filter_bar.js`` (declared as this component's ``media``) wires the
chrome; widget media (search_select.js, range_slider.js,
date_range_picker.js) bubbles up from the contained widgets via the node
tree, so the view never threads ``scripts=`` by hand.
"""
media = _FILTER_BAR_MEDIA
def __init__(
self,
filter_json: str = "",
preset_list_url: str = "",
preset_save_url: str = "",
) -> None:
self.filter_json = filter_json
self.preset_list_url = preset_list_url
self.preset_save_url = preset_save_url
self.existing = _filter_parse(filter_json)
def build_fields(self) -> list:
"""Return the per-entity filter body. Implemented by each subclass."""
raise NotImplementedError
def render(self) -> Node:
return Div( return Div(
attributes=[("id", "filter-bar"), ("class", "mb-6")], attributes=[("id", "filter-bar"), ("class", "mb-6")],
children=[ children=[
@@ -704,8 +742,8 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
), ),
], ],
children=[ children=[
Component( Element(
tag_name="form", "form",
attributes=[ attributes=[
("id", _FILTER_FORM_ID), ("id", _FILTER_FORM_ID),
("onsubmit", "return applyFilterBar(event)"), ("onsubmit", "return applyFilterBar(event)"),
@@ -716,13 +754,15 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
("type", "hidden"), ("type", "hidden"),
("id", _FILTER_INPUT_ID), ("id", _FILTER_INPUT_ID),
("name", "filter"), ("name", "filter"),
# NB: Component escapes attribute values, so the # NB: attribute values are escaped, so the
# raw JSON is passed through (no double-escape). # raw JSON passes through (no double-escape).
("value", filter_json), ("value", self.filter_json),
], ],
), ),
*fields, *self.build_fields(),
_filter_action_row(preset_list_url, preset_save_url), _filter_action_row(
self.preset_list_url, self.preset_save_url
),
], ],
), ),
], ],
@@ -731,19 +771,31 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
) )
def FilterBar( class FilterBar(_FilterBarBase):
"""Collapsible filter bar for the Game list."""
def __init__(
self,
filter_json: str = "", filter_json: str = "",
status_options: list[LabeledOption] | None = None, status_options: list[LabeledOption] | None = None,
preset_list_url: str = "", preset_list_url: str = "",
preset_save_url: str = "", preset_save_url: str = "",
) -> SafeText: ) -> None:
"""Collapsible filter bar for the Game list.""" super().__init__(filter_json, preset_list_url, preset_save_url)
self.status_options = status_options
def build_fields(self) -> list:
return _game_fields(self.existing, self.status_options)
def _game_fields(
existing: dict, status_options: list[LabeledOption] | None = None
) -> list:
from games.models import Game, Purchase from games.models import Game, Purchase
if status_options is None: if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status] status_options = [(s.value, s.label) for s in Game.Status]
existing = _filter_parse(filter_json)
status_choice = _filter_get_choice(existing, "status") status_choice = _filter_get_choice(existing, "status")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
platform_group_choice = _filter_get_choice(existing, "platform_group") platform_group_choice = _filter_get_choice(existing, "platform_group")
@@ -1055,7 +1107,7 @@ def FilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def _find_label(options: list[LabeledOption], value: str) -> str: def _find_label(options: list[LabeledOption], value: str) -> str:
@@ -1065,13 +1117,16 @@ def _find_label(options: list[LabeledOption], value: str) -> str:
return value return value
def SessionFilterBar( class SessionFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Session list.""" """Collapsible filter bar for the Session list."""
def build_fields(self) -> list:
return _session_fields(self.existing)
def _session_fields(existing: dict) -> list:
from games.models import Game, Session from games.models import Game, Session
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game") game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device") device_choice = _filter_get_choice(existing, "device")
note_value = existing.get("note", {}).get("value", "") note_value = existing.get("note", {}).get("value", "")
@@ -1169,18 +1224,21 @@ def SessionFilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def PurchaseFilterBar( class PurchaseFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Purchase list.""" """Collapsible filter bar for the Purchase list."""
def build_fields(self) -> list:
return _purchase_fields(self.existing)
def _purchase_fields(existing: dict) -> list:
from games.models import Purchase from games.models import Purchase
type_options = Purchase.TYPES type_options = Purchase.TYPES
ownership_options = Purchase.OWNERSHIP_TYPES ownership_options = Purchase.OWNERSHIP_TYPES
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "games") game_choice = _filter_get_choice(existing, "games")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
type_choice = _filter_get_choice(existing, "type") type_choice = _filter_get_choice(existing, "type")
@@ -1352,14 +1410,19 @@ def PurchaseFilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> SafeText: class DeviceFilterBar(_FilterBarBase):
"""Collapsible filter bar for the Device list.""" """Collapsible filter bar for the Device list."""
def build_fields(self) -> list:
return _device_fields(self.existing)
def _device_fields(existing: dict) -> list:
from games.models import Device from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type") type_choice = _filter_get_choice(existing, "type")
@@ -1379,15 +1442,17 @@ def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> S
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def PlatformFilterBar( class PlatformFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list.""" """Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
def build_fields(self) -> list:
return _platform_fields(self.existing)
def _platform_fields(existing: dict) -> list:
name_value = existing.get("name", {}).get("value", "") name_value = existing.get("name", {}).get("value", "")
name_modifier = existing.get("name", {}).get("modifier", "EQUALS") name_modifier = existing.get("name", {}).get("modifier", "EQUALS")
group_value = existing.get("group", {}).get("value", "") group_value = existing.get("group", {}).get("value", "")
@@ -1418,14 +1483,17 @@ def PlatformFilterBar(
], ],
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def PlayEventFilterBar( class PlayEventFilterBar(_FilterBarBase):
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list.""" """Collapsible filter bar for the PlayEvent list."""
existing = _filter_parse(filter_json)
def build_fields(self) -> list:
return _playevent_fields(self.existing)
def _playevent_fields(existing: dict) -> list:
game_choice = _filter_get_choice(existing, "game") game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish") days_min, days_max = _parse_range(existing, "days_to_finish")
@@ -1456,7 +1524,7 @@ def PlayEventFilterBar(
max_placeholder="e.g. 30", max_placeholder="e.g. 30",
), ),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return fields
def StringFilter( def StringFilter(
@@ -1464,7 +1532,7 @@ def StringFilter(
value: str = "", value: str = "",
modifier: str = "EQUALS", modifier: str = "EQUALS",
placeholder: str = "", placeholder: str = "",
) -> SafeText: ) -> Node:
"""Renders a string filter with 8 modifier radio options and a text input.""" """Renders a string filter with 8 modifier radio options and a text input."""
from common.criteria import Modifier from common.criteria import Modifier
+168 -207
View File
@@ -1,4 +1,11 @@
"""Generic HTML primitives (no domain knowledge).""" """Generic HTML primitives (no domain knowledge).
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
per tag: they are generated from a whitelist via :func:`_html_element`, each a
thin builder over the single :class:`Element` node class. Only elements that add
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
"""
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
@@ -6,7 +13,20 @@ 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 (
Attributes,
Child,
Children,
Element,
Fragment,
HTMLAttribute,
Media,
Node,
Safe,
as_attributes,
as_children,
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 +47,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: Attributes | None = None,
children: Children = 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: Child,
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(
@@ -69,7 +117,7 @@ def _popover_html(
children=[popover_content], children=[popover_content],
), ),
Div(attributes=[("data-popper-arrow", "")]), Div(attributes=[("data-popper-arrow", "")]),
mark_safe( # nosec — intentional HTML comment for Tailwind JIT Safe( # nosec — intentional HTML comment for Tailwind JIT
"<!-- for Tailwind CSS to generate decoration-dotted CSS " "<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->" "from Python component -->"
), ),
@@ -79,24 +127,24 @@ def _popover_html(
], ],
) )
return mark_safe(span + "\n" + div) return Fragment(span, div, separator="\n")
def Popover( def Popover(
popover_content: str, popover_content: Child,
wrapped_content: str = "", wrapped_content: str = "",
wrapped_classes: str = "", wrapped_classes: str = "",
children: list[HTMLTag] | None = None, children: Children = None,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
id: str = "", id: str = "",
) -> str: ) -> Node:
children = children or [] children = as_children(children)
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,
@@ -108,12 +156,12 @@ def Popover(
def PopoverTruncated( def PopoverTruncated(
input_string: str, input_string: str,
popover_content: str = "", popover_content: Child = "",
popover_if_not_truncated: bool = False, popover_if_not_truncated: bool = False,
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.
@@ -139,11 +187,11 @@ def PopoverTruncated(
def A( def A(
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = 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.
@@ -151,7 +199,7 @@ def A(
- url_name: URL pattern name, resolved via reverse() - url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is - href: Literal path string passed through as-is
""" """
attributes = attributes or [] attributes = as_attributes(attributes)
children = children or [] children = children or []
if url_name is not None and href is not None: if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
@@ -161,14 +209,14 @@ 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
) )
def Button( def Button(
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
size: str = "base", size: str = "base",
icon: bool = False, icon: bool = False,
color: str = "blue", color: str = "blue",
@@ -179,8 +227,8 @@ def Button(
title: str = "", title: str = "",
onclick: str = "", onclick: str = "",
name: str = "", name: str = "",
) -> SafeText: ) -> Element:
attributes = attributes or [] attributes = as_attributes(attributes)
children = children or [] children = children or []
# Separate custom class from other generic attributes # Separate custom class from other generic attributes
@@ -224,8 +272,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 +315,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 +332,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 +342,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 +353,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 +374,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: Attributes | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> SafeText: ) -> Element:
attributes = attributes or [] attributes = as_attributes(attributes)
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(
@@ -406,10 +389,10 @@ def Checkbox(
label: str | None = None, label: str | None = None,
checked: bool = False, checked: bool = False,
value: str = "1", value: str = "1",
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
) -> SafeText: ) -> Node:
"""A filter-agnostic Checkbox component.""" """A filter-agnostic Checkbox component."""
attributes = attributes or [] attributes = as_attributes(attributes)
input_attrs = [ input_attrs = [
("name", name), ("name", name),
("value", value), ("value", value),
@@ -438,10 +421,10 @@ def Radio(
label: str | None = None, label: str | None = None,
checked: bool = False, checked: bool = False,
value: str = "", value: str = "",
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
) -> SafeText: ) -> Node:
"""A filter-agnostic Radio component.""" """A filter-agnostic Radio component."""
attributes = attributes or [] attributes = as_attributes(attributes)
input_attrs = [ input_attrs = [
("name", name), ("name", name),
("value", value), ("value", value),
@@ -465,16 +448,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
@@ -493,8 +466,8 @@ def Pill(
removable: bool = False, removable: bool = False,
extra_class: str = "", extra_class: str = "",
label_slot: bool = False, label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | 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``
@@ -505,23 +478,23 @@ def Pill(
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``). markup single-sourced — see ``search_select.py``).
""" """
attributes = attributes or [] attributes = as_attributes(attributes)
pill_class = f"{_PILL_CLASS} {extra_class}".strip() pill_class = f"{_PILL_CLASS} {extra_class}".strip()
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")] pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
if value != "": if value != "":
pill_attrs.append(("data-value", str(value))) pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes) pill_attrs.extend(attributes)
label_child: HTMLTag = ( label_child: "Node | str" = (
Span(attributes=[("data-search-select-label", "")], children=[label]) Span(attributes=[("data-search-select-label", "")], children=[label])
if label_slot if label_slot
else label else label
) )
children: list[HTMLTag] = [label_child] children: list["Node | str"] = [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", ""),
@@ -535,9 +508,12 @@ def Pill(
return Span(attributes=pill_attrs, children=children) return Span(attributes=pill_attrs, children=children)
def CsrfInput(request) -> SafeText: def CsrfInput(request) -> Node:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.""" """Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.
return mark_safe(
Returns a ``Safe`` node (not a safe string): it is always used as a tree
child, and only nodes render unescaped now."""
return Safe(
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">' f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
) )
@@ -554,11 +530,22 @@ def ExternalScript(url: str) -> SafeText:
return mark_safe(f'<script src="{url}"></script>') return mark_safe(f'<script src="{url}"></script>')
def StaticScript(filename: str) -> SafeText:
"""A plain (classic, non-module) `<script src=...>` tag for a static JS
file — for vendored UMD bundles, which break inside module scope."""
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
@@ -567,8 +554,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 ""
@@ -579,7 +566,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())"
@@ -632,17 +620,19 @@ document.addEventListener('DOMContentLoaded', () => {{
picker.update(); picker.update();
}} }}
}}); }});
</script>""") </script>""",
media=_YEAR_PICKER_MEDIA,
)
def AddForm( def AddForm(
form, form,
*, *,
request, request,
fields: SafeText | str | None = None, fields: Node | SafeText | str | None = None,
additional_row: SafeText | str = "", additional_row: Node | 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
@@ -651,11 +641,11 @@ def AddForm(
is applied to the main Submit button (the session form passes "" to match is applied to the main Submit button (the session form passes "" to match
its original markup). its original markup).
""" """
field_markup = fields if fields is not None else mark_safe(form.as_div()) field_markup = fields if fields is not None else 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),
@@ -683,10 +673,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(
@@ -699,7 +689,7 @@ def SearchField(
Div( Div(
attributes=[("class", "relative")], attributes=[("class", "relative")],
children=[ children=[
mark_safe( Safe(
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">' '<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" ' '<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
'fill="none" viewBox="0 0 24 24">' 'fill="none" viewBox="0 0 24 24">'
@@ -724,8 +714,8 @@ def SearchField(
("required", ""), ("required", ""),
], ],
), ),
Component( Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "submit"), ("type", "submit"),
( (
@@ -746,13 +736,13 @@ def SearchField(
def H1( def H1(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = 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
@@ -767,21 +757,20 @@ 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=as_children(children) + ([badge_html] if badge_html else []),
+ ([badge_html] if badge_html else []),
) )
def Modal( def Modal(
modal_id: str, modal_id: str,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = 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),
( (
@@ -799,52 +788,24 @@ def Modal(
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900", "shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
), ),
], ],
children=(children if isinstance(children, list) else [children]), children=as_children(children),
), ),
], ],
) )
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: Children = None,
) -> SafeText: ) -> Element:
"""Styled table cell.""" """Styled table cell."""
children = children or [] children = children or []
return Td( return Td(
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")], attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )
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": ..., ...}
@@ -879,7 +840,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(
@@ -903,18 +864,18 @@ def TableRow(data: dict | list | None = None) -> SafeText:
def Icon( def Icon(
name: str, name: str,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | 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: Children = 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",
@@ -922,7 +883,7 @@ def TableHeader(
"text-gray-900 bg-white dark:text-white dark:bg-gray-900", "text-gray-900 bg-white dark:text-white dark:bg-gray-900",
), ),
], ],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )
@@ -1001,11 +962,11 @@ def _pagination_nav(page_obj, elided_page_range, request) -> str:
def SimpleTable( def SimpleTable(
columns: list[str] | None = None, columns: list[str] | None = None,
rows: list | None = None, rows: list | None = None,
header_action: SafeText | str | None = None, header_action: Child | None = None,
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 []
@@ -1024,7 +985,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">'
@@ -1044,7 +1005,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
+32 -30
View File
@@ -21,11 +21,13 @@ user types.
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import TypedDict from typing import TypedDict
from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.primitives import Div, Input, Pill, Span, Template
# Both comboboxes are wired by search_select.js.
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
value: str | int value: str | int
@@ -141,11 +143,11 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
return [(f"data-{key}", str(value)) for key, value in data.items()] return [(f"data-{key}", str(value)) for key, value in data.items()]
def _hidden_input(name: str, value) -> SafeText: def _hidden_input(name: str, value) -> Node:
return Input(type="hidden", attributes=[("name", name), ("value", str(value))]) return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
def _label_slot(text: str, *, extra_class: str = "") -> SafeText: def _label_slot(text: str, *, extra_class: str = "") -> Node:
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this """A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side.""" thing the JS sets — all classes and structure stay server-side."""
@@ -159,7 +161,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}} _BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> SafeText: def _option_row(option: SearchSelectOption) -> Node:
return Div( return Div(
attributes=[ attributes=[
("data-search-select-option", ""), ("data-search-select-option", ""),
@@ -174,14 +176,14 @@ def _option_row(option: SearchSelectOption) -> SafeText:
def _combobox_shell( def _combobox_shell(
*, *,
container_attributes: list[HTMLAttribute], container_attributes: Attributes,
pills: SafeText, pills: Node,
search_attributes: list[HTMLAttribute], search_attributes: Attributes,
options_children: list[SafeText], options_children: list[Node],
always_visible: bool, always_visible: bool,
items_visible: int, items_visible: int,
templates: list[SafeText] | None = None, templates: list[Node] | None = None,
) -> SafeText: ) -> Node:
"""Assemble the shared, domain-agnostic combobox skeleton. """Assemble the shared, domain-agnostic combobox skeleton.
Every combobox built on top of this shell has the same three regions in the Every combobox built on top of this shell has the same three regions in the
@@ -213,7 +215,7 @@ def _combobox_shell(
children=[*options_children, no_results], children=[*options_children, no_results],
) )
children: list[SafeText] = [pills, search, options_panel, *(templates or [])] children: list[Node] = [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children) return Div(attributes=container_attributes, children=children)
@@ -232,7 +234,7 @@ def SearchSelect(
id: str = "", id: str = "",
sync_url: bool = False, sync_url: bool = False,
autofocus: bool = False, autofocus: bool = False,
) -> SafeText: ) -> Node:
"""Render the search-select widget. See module docstring for the contract.""" """Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(option) for option in (selected or [])] selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(option) for option in (options or [])] options = [_normalize_option(option) for option in (options or [])]
@@ -242,7 +244,7 @@ def SearchSelect(
# pill — the committed label shows inside the search box instead, with a # pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside # lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-search-select-pills]` so the JS reads/writes values uniformly. # `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = [] pills_children: list[Node] = []
search_value = "" search_value = ""
if multi_select: if multi_select:
for option in selected: for option in selected:
@@ -283,7 +285,7 @@ def SearchSelect(
# ── Templates the JS clones: a row when results are fetched, a pill when # ── Templates the JS clones: a row when results are fetched, a pill when
# multi-select adds chosen items. ── # multi-select adds chosen items. ──
templates: list[SafeText] = [] templates: list[Node] = []
if search_url: if search_url:
templates.append( templates.append(
Template( Template(
@@ -322,12 +324,12 @@ def SearchSelect(
always_visible=always_visible, always_visible=always_visible,
items_visible=items_visible, items_visible=items_visible,
templates=templates, templates=templates,
) ).with_media(_SEARCH_SELECT_MEDIA)
def _filter_remove_button() -> SafeText: def _filter_remove_button() -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-pill-remove", ""), ("data-pill-remove", ""),
@@ -338,7 +340,7 @@ def _filter_remove_button() -> SafeText:
) )
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText: def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude".""" """An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
symbol = "" if kind == "include" else "" symbol = "" if kind == "include" else ""
css = ( css = (
@@ -357,7 +359,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
) )
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText: def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)").""" """The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Span( return Span(
attributes=[ attributes=[
@@ -369,9 +371,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
) )
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText: def _filter_action_button(action: str, symbol: str, title: str) -> Node:
return Component( return Element(
tag_name="button", "button",
attributes=[ attributes=[
("type", "button"), ("type", "button"),
("data-search-select-action", action), ("data-search-select-action", action),
@@ -382,7 +384,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
) )
def _filter_option_row(value: str | int, label: str) -> SafeText: def _filter_option_row(value: str | int, label: str) -> Node:
"""A value row with include (+) and exclude () buttons.""" """A value row with include (+) and exclude () buttons."""
return Div( return Div(
attributes=[ attributes=[
@@ -404,7 +406,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
) )
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText: def _filter_modifier_row(modifier_value: str, label: str) -> Node:
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text """A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel.""" filter never hides it — modifiers stay visible at the top of the panel."""
return Div( return Div(
@@ -432,7 +434,7 @@ def FilterSelect(
placeholder: str = "Search…", placeholder: str = "Search…",
id: str = "", id: str = "",
free_text: bool = False, free_text: bool = False,
) -> SafeText: ) -> Node:
"""Include/exclude filter combobox built on the shared ``_combobox_shell``. """Include/exclude filter combobox built on the shared ``_combobox_shell``.
Like ``SearchSelect`` but each value row carries +/ buttons that add an Like ``SearchSelect`` but each value row carries +/ buttons that add an
@@ -470,7 +472,7 @@ def FilterSelect(
# pills — but the stored state guarantees they never coexist, so we render # pills — but the stored state guarantees they never coexist, so we render
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL / # both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
# INCLUDES_ONLY) coexist with value pills and render side by side. # INCLUDES_ONLY) coexist with value pills and render side by side.
pills_children: list[SafeText] = [] pills_children: list[Node] = []
if active_modifier_label: if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label)) pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
for option in included: for option in included:
@@ -504,7 +506,7 @@ def FilterSelect(
# ── Templates the JS clones: include/exclude pills (added on click), the # ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ── # modifier pill (when modifiers exist), and a value row (when fetched). ──
templates: list[SafeText] = [ templates: list[Node] = [
Template( Template(
attributes=[("data-search-select-template", "pill-include")], attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")], children=[_filter_value_pill(_BLANK_OPTION, "include")],
@@ -557,7 +559,7 @@ def FilterSelect(
always_visible=False, always_visible=False,
items_visible=items_visible, items_visible=items_visible,
templates=templates, templates=templates,
) ).with_media(_SEARCH_SELECT_MEDIA)
def searchselect_selected( def searchselect_selected(
+39 -12
View File
@@ -8,6 +8,7 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
""" """
import json import json
from typing import TYPE_CHECKING
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@@ -19,6 +20,9 @@ from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date from games.templatetags.version import version, version_date
if TYPE_CHECKING:
from common.components import Node
# Static head script that sets the dark/light class before paint (avoids FOUC). # Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """<script> _THEME_FOUC_SCRIPT = """<script>
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -182,10 +186,16 @@ def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText: def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node":
"""Top navigation bar.""" """Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
than a hand-built element tree — trusted HTML belongs in a ``Safe`` node,
not a ``mark_safe`` string."""
from common.components import Safe
logo = static("icons/schedule.png") logo = static("icons/schedule.png")
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default"> return Safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse("games:index")}" <a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse"> class="flex items-center space-x-3 rtl:space-x-reverse">
@@ -269,16 +279,30 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
def Page( def Page(
content: SafeText | str, content: "Node | SafeText | str",
*, *,
request: HttpRequest, request: HttpRequest,
title: str = "", title: str = "",
scripts: SafeText | str = "", scripts: "Node | SafeText | str" = "",
mastered: bool = False, mastered: bool = False,
) -> SafeText: ) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent).""" """Assemble a full HTML document around `content` (the fast_app equivalent).
Scripts are collected from `content`'s component tree: every component
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
for the whole page. The `scripts` argument remains for page-specific glue
that isn't owned by a reusable component (e.g. the add-form helpers).
"""
from common.components import ModuleScript, StaticScript, collect_media
from games.views.general import global_current_year, model_counts from games.views.general import global_current_year, model_counts
media = collect_media(content)
collected_scripts = "".join(
[str(ModuleScript(name)) for name in media.js]
+ [str(StaticScript(name)) for name in media.js_external]
)
all_scripts = collected_scripts + (str(scripts) if scripts else "")
counts = model_counts(request) counts = model_counts(request)
year = global_current_year(request)["global_current_year"] year = global_current_year(request)["global_current_year"]
navbar = Navbar( navbar = Navbar(
@@ -309,9 +333,12 @@ def Page(
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n' f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
f" {django_htmx_script(nonce=None)}\n" f" {django_htmx_script(nonce=None)}\n"
f' <link rel="stylesheet" href="{static("base.css")}" />\n' f' <link rel="stylesheet" href="{static("base.css")}" />\n'
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n' # Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n' # served locally so pages work offline (and in browser tests). The mask
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n' # plugin must load before Alpine core; both stay deferred.
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
f" {_THEME_FOUC_SCRIPT}\n" f" {_THEME_FOUC_SCRIPT}\n"
" </head>\n" " </head>\n"
) )
@@ -325,7 +352,7 @@ def Page(
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n' f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n' f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n" " </div>\n"
f" {scripts}\n" f" {all_scripts}\n"
f" {_main_script(mastered)}\n" f" {_main_script(mastered)}\n"
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n" " <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n' ' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
@@ -339,10 +366,10 @@ def Page(
def render_page( def render_page(
request: HttpRequest, request: HttpRequest,
content: SafeText | str, content: "Node | SafeText | str",
*, *,
title: str = "", title: str = "",
scripts: SafeText | str = "", scripts: "Node | SafeText | str" = "",
mastered: bool = False, mastered: bool = False,
status: int = 200, status: int = 200,
) -> HttpResponse: ) -> HttpResponse:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
# HTML + JS component authoring — design
**Date:** 2026-06-13
**Status:** Approved (design); pending implementation plan
**Branch context:** follows the lazy node-tree component system (`Element`/`Safe`/`Fragment`/`Media`) and the `Children`/`Attributes` typing work.
## Problem
Trusted HTML and JavaScript are authored as Python f-strings in several places. Two distinct pains:
- **HTML-as-string** — `Navbar`, `_TOAST_CONTAINER`, the played-row markup skeleton, and the generally verbose `Element("div", attributes=[...], children=[...])` call shape.
- **JS-in-string** — the genuinely ugly ones: `GameStatusSelector` (~70 lines) and `SessionDeviceSelector` (~50 lines) inline an Alpine `x-data="{...}"` blob with `fetchWithHtmxTriggers`, server-value interpolation (`{game.status}`), **and** `{{ }}` brace-doubling throughout; `_PLAYED_ROW_TEMPLATE` dodges the brace collision entirely by switching to `@@TOKEN@@` placeholders + a `.replace()` loop.
You cannot node-tree JavaScript, so the JS pain needs a different answer than the HTML pain. The newer widgets (`search_select`, `range_slider`, `filter_bar`) already moved behavior into real `.js` files wired by `onSwap` + `data-*` attributes; the Alpine selectors are the holdouts that still inline their JS.
## Goal
Establish the *right* way to author interactive, server-rendered components in this codebase, and convert a few exemplars to prove it. North-star principle:
> The server never writes a line of JavaScript. The server↔client boundary is a typed, declarative contract. Behavior lives in real, tooled TypeScript files.
## Decisions (locked during brainstorming)
| Decision | Choice |
| --- | --- |
| HTML authoring | **htpy-*style* sugar on the existing `Element`** (not the htpy library) — keeps `Media`/`collect_media`, no build step |
| JS runtime model | **Custom Elements** (Web Components), light DOM |
| Server↔client contract | **Typed contract + codegen** (one Python `Props` type → generated TS interface + reader) |
| JS language | **TypeScript** (real `.ts`, compiled) |
| Build tool | **`tsc` per-module** (no bundler) — preserves per-component `Media` loading |
| Alpine, for converted components | **Retired** — behavior rewritten as vanilla TS in the element class |
| Exemplars | **`GameStatusSelector` + `SessionDeviceSelector` + played-row** |
| Compiled output | **Build-only, gitignored** (produced by `make` + Docker) |
| Existing hand-written `.js` | **Left as-is**, migrated to TS later |
## Architecture
Three independent layers composing through one typed seam:
```
Python (server) TypeScript (client)
───────────────── ───────────────────
htpy-style Element ──renders──► <game-status-selector ──connectedCallback──► game-status-selector.ts
+ Media (kept) game-id="3" status="f"> (vanilla DOM behavior)
│ ▲
└── GameStatusSelectorProps ─codegen─┘ generated props.ts (interface + typed reader)
(one Python type = the whole server↔client contract)
```
- **Layer 1 — htpy-style HTML** removes HTML-string / verbose-`Element` ugliness, pure Python, no build, `Media` untouched.
- **Layer 2 — Custom Elements (TS)** removes JS-string ugliness; behavior in real typed modules with a native lifecycle.
- **Layer 3 — Typed contract codegen** makes the seam type-safe in both languages from a single Python source.
### Layer 1 — htpy-style sugar on `Element`
Additive only. Existing `Element("div", attributes=[...], children=[...])` and `Div([("class","x")], "hi")` keep working.
- **Attributes as kwargs:** `Div(class_="card", hx_get="/x", disabled=True)`. Translation: trailing `_` stripped (`class_``class`); inner `_``-` (`hx_get``hx-get`, `data_id``data-id`); `True`→bare attribute, `False`/`None`→omitted.
- **Children via `[]`:** `Div(class_="card")[H1["Title"], body]`. `Element.__getitem__` normalizes through the existing `as_children` and returns an `Element` carrying the same attributes and media.
The result is still a walkable `Element` tree, so `collect_media` / `Media` are unaffected. This is the "htpy feel on our own node so the asset system survives" decision.
Example:
```python
Div(class_="flex gap-2 items-center")[
Icon("play"),
Span(class_="label")[name],
]
```
### Layer 2 — Custom Elements (TypeScript, light DOM)
- Python builder emits a **semantic tag**: `Element("game-status-selector", attrs).with_media(Media(js=("dist/elements/game-status-selector.js",)))`.
- **Light DOM** (no shadow root — Tailwind's global classes must apply). The server renders the inner markup (htpy-style); the element enhances it.
- **Native lifecycle replaces `onSwap`:** `connectedCallback()` fires when the browser parses or htmx-swaps the element in; `disconnectedCallback()` provides free teardown. No init registry, no guard flags.
- Behavior is **vanilla TS** — the element class owns its state (dropdown open/closed, PATCH-on-select via `fetchWithHtmxTriggers`). Alpine retired for these three.
- Source `ts/elements/<tag>.ts` → compiled `games/static/js/dist/elements/<tag>.js`, loaded only on pages that use it (via `Media`).
### Layer 3 — Typed contract (one Python type → the whole seam)
Each element declares its props once, in Python:
```python
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
```
- The **Python builder** takes these typed args and serializes them to kebab-case attributes (`game-id="3"`).
- **Codegen** reads the registered Props types and emits, per component, into `ts/generated/props.ts`:
- an **interface**`GameStatusSelectorProps { gameId: number; status: string; csrf: string }`, and
- a **typed reader**`readGameStatusSelectorProps(el): GameStatusSelectorProps` that pulls and parses attributes (`Number(el.getAttribute("game-id"))`, etc.).
- The element imports the generated reader. The entire server↔client boundary is generated from one Python type: rename `game_id` in Python, regenerate, and `tsc` fails until the element updates. Drift is caught at build time; no hand-written `getAttribute` soup, no silent attr-name drift.
Type map: `int`/`float``number`, `str``string`, `bool``boolean`. Field `game_id` → attr `game-id` → TS prop `gameId`. Reader parsing follows the type (number → `Number(...)`, bool → presence / `=== "true"`, string → `getAttribute(...) ?? ""`).
## Toolchain (`tsc` per-module, build-only)
Layout:
```
ts/
elements/game-status-selector.ts # hand-written element classes
generated/props.ts # codegen output (gitignored)
globals.d.ts # ambient: window.fetchWithHtmxTriggers, htmx
tsconfig.json # strict, ES2022, lib [ES2022, DOM, DOM.Iterable]
# rootDir: ts/ → outDir: games/static/js/dist/
```
- **`games/static/js/dist/` is the only compiled output**, trivially gitignored, never colliding with hand-written `.js`. `Media` references `dist/elements/...`.
- **package.json**: add `typescript` devDep; scripts `build:ts` (`tsc -p tsconfig.json`), `watch:ts` (`tsc -p tsconfig.json --watch`).
- **Makefile**: `make ts` = codegen → `tsc`; `make dev` also runs `tsc --watch` (beside Django runserver + Tailwind watch); `make check` gains `tsc --noEmit` as a drift gate.
- **.gitignore**: `games/static/js/dist/`, `ts/generated/`.
- **Docker**: add a `make ts` step in the image build (npm already present for Tailwind); compiled JS baked into the image. Runtime stays offline.
- **TS lint/format**: deferred — `tsc --strict` is the only gate for now.
### Codegen mechanics
- A registry maps `tag → Props type` (e.g. a decorator `@element("game-status-selector", GameStatusSelectorProps)` on the Python builder, collected into a module-level registry).
- A Django management command (or script) imports the registry and writes `ts/generated/props.ts` (interface + reader per component).
- **Ordering:** codegen runs before `tsc` (the generated file is a `tsc` input). CI runs codegen then `tsc --noEmit`, so Python/TS drift fails the build. No committed generated artifact to diff against — `tsc` failing on drift is the gate.
## Exemplar conversions
1. **`GameStatusSelector``<game-status-selector game-id status csrf>`** — Python builds the light-DOM htpy-style; `game-status-selector.ts` wires the dropdown toggle + click→PATCH `/api/games/{id}/status` via `fetchWithHtmxTriggers` with CSRF, and updates the displayed status. Deletes the ~70-line f-string + brace-doubling.
2. **`SessionDeviceSelector``<session-device-selector>`** — same shape; PATCH `/api/session/{id}/device`.
3. **played-row → `<play-event-row>`** (non-Alpine) — deletes `_PLAYED_ROW_TEMPLATE` and the `@@TOKEN@@` / `.replace()` hack; Python builds markup htpy-style; `play-event-row.ts` owns the dropdown + add-playthrough POST. URLs are server-reversed and passed as attributes. Proves the pattern is not Alpine-only.
## Testing
- **Python**: builders render the correct tag + attributes (extend `test_components` / `test_rendered_pages`); assert no f-string remnants remain.
- **Type-check**: `tsc --noEmit` in `make check` — type errors, including contract drift, fail CI.
- **e2e (Playwright)**: real Chromium upgrades the custom elements natively; port/extend the existing widget-e2e pattern for all three (open dropdown → select → PATCH → DOM updates).
## Risks and mitigations
1. **Element module must be loaded before its tag appears.** Full-page render loads the module via `Media`; htmx row-swaps reuse the already-defined element. Constraint to document: a fragment response that introduces a brand-new element type must include that element's `Media`. (Same limitation class as today's "`onSwap` needs the script present.")
2. **A build step is now required** for `make dev` and Docker. One-time wiring, mitigated by Make/Docker integration.
3. **First TypeScript in the repo** — adds `typescript`, `tsconfig.json`, a Docker build step. Scoped to `ts/`; existing `.js` untouched.
4. **CSRF/PATCH parity** — the vanilla TS must replicate the Alpine version's fetch/CSRF/`HX-Trigger` behavior; it reuses the existing `fetchWithHtmxTriggers`; e2e guards it.
5. **Codegen ↔ build ordering** — codegen must precede `tsc`; encoded in `make ts`.
## Out of scope (YAGNI)
- Migrating the existing hand-written `.js` to TypeScript (later, incrementally).
- Bundling / minification of app JS.
- Shadow DOM / scoped styles.
- A general island / props-blob hydration runtime (custom elements cover these three).
- TS lint/format tooling (prettier/eslint).
## Future on-ramps (not now)
- **More custom elements**: migrate the remaining `onSwap` widgets to custom elements once the pattern is proven.
- **Existing `.js` → TS**: incremental, file by file (`tsc` checks mixed projects).
- The typed contract already positions the boundary for full type-safety as more client code becomes TS.
+4 -3
View File
@@ -21,9 +21,10 @@ def _bar_page(filter_json: str = "") -> str:
<html> <html>
<head> <head>
<title>Boolean filter E2E</title> <title>Boolean filter E2E</title>
<script src="/static/js/range_slider.js" defer></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/search_select.js" defer></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/filter_bar.js" defer></script> <script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+4 -3
View File
@@ -29,9 +29,10 @@ def _bar_page(filter_json: str = "") -> str:
<html> <html>
<head> <head>
<title>Date filter E2E</title> <title>Date filter E2E</title>
<script src="/static/js/range_slider.js" defer></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/search_select.js" defer></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/filter_bar.js" defer></script> <script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} {PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+4 -3
View File
@@ -28,10 +28,11 @@ def _bar_page(filter_json: str = "") -> str:
<html> <html>
<head> <head>
<title>Date range picker E2E</title> <title>Date range picker E2E</title>
<script src="/static/js/range_slider.js" defer></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/search_select.js" defer></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/date_range_picker.js" defer></script> <script src="/static/js/date_range_picker.js" defer></script>
<script src="/static/js/filter_bar.js" defer></script> <script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} {PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+4 -3
View File
@@ -17,9 +17,10 @@ def _bar_page(filter_json: str = "") -> str:
<html> <html>
<head> <head>
<title>Range Slider E2E</title> <title>Range Slider E2E</title>
<script src="/static/js/range_slider.js" defer></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/search_select.js" defer></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/filter_bar.js" defer></script> <script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+12 -4
View File
@@ -4,35 +4,43 @@ from django.http import HttpResponse
from django.test import override_settings from django.test import override_settings
from common.components import SearchSelect from common.components import SearchSelect
def e2e_test_view(request): def e2e_test_view(request):
html = f""" html = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>SearchSelect E2E Test</title> <title>SearchSelect E2E Test</title>
<script src="/static/js/search_select.js" defer></script> <!-- search_select.js is an ES module and initializes via onSwap(),
which rides on htmx.onLoad — so htmx must be present. -->
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/search_select.js"></script>
</head> </head>
<body> <body>
<div style="padding: 50px;"> <div style="padding: 50px;">
{SearchSelect( {
SearchSelect(
name="games", name="games",
selected=[{"value": "7", "label": "Game A", "data": {}}], selected=[{"value": "7", "label": "Game A", "data": {}}],
options=[ options=[
{"value": "7", "label": "Game A", "data": {}}, {"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}}, {"value": "8", "label": "Game B", "data": {}},
], ],
multi_select=False multi_select=False,
)} )
}
</div> </div>
</body> </body>
</html> </html>
""" """
return HttpResponse(html) return HttpResponse(html)
urlpatterns = [ urlpatterns = [
path("test-search-select/", e2e_test_view), path("test-search-select/", e2e_test_view),
] ]
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e") @override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_backspace_clears_single_select(live_server, page): def test_search_select_backspace_clears_single_select(live_server, page):
+4 -3
View File
@@ -16,9 +16,10 @@ def _bar_page(filter_json: str = "") -> str:
<html> <html>
<head> <head>
<title>String filter E2E</title> <title>String filter E2E</title>
<script src="/static/js/range_slider.js" defer></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/search_select.js" defer></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/filter_bar.js" defer></script> <script src="/static/js/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} {PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+135
View File
@@ -0,0 +1,135 @@
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
add_purchase.js) and their onSwap() initialization lifecycle.
These run a real Chromium via pytest-playwright against pytest-django's
``live_server``. All JavaScript under test is served locally from
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
vendored), so no network access is needed beyond the live server itself.
Browser binaries must be installed once: ``uv run playwright install chromium``.
"""
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def open_filter_bar(page: Page) -> None:
page.click("#filter-bar button:has-text('Filters')")
expect(page.locator("#filter-bar-body")).to_be_visible()
def status_filter_widget(page: Page):
return page.locator('[data-search-select][data-name="status"]')
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
"""Clicking into a FilterSelect search box opens its options panel —
proof that onSwap ran the widget initializer on the initial page load."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
options_panel = widget.locator("[data-search-select-options]")
expect(options_panel).to_be_visible()
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
# only becomes interactable through the initialized panel.
expect(
options_panel.locator("[data-search-select-modifier-option]").first
).to_have_text("(Any)")
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
"""Clicking an enum option row adds an include pill (full widget wiring)."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
widget.locator('[data-search-select-option][data-label="Finished"]').click()
pill = widget.locator("[data-search-select-pills] [data-pill]")
expect(pill).to_have_count(1)
expect(pill).to_contain_text("Finished")
def test_range_slider_mode_toggle_fires_exactly_once(
authenticated_page: Page, live_server
):
"""One click on the mode toggle flips the slider from range to point mode
exactly once. Double-bound listeners (the old force-re-init bug) would
flip it twice, leaving data-mode unchanged."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
block = page.locator(".range-slider-block").first
slider = block.locator(".range-slider")
expect(slider).to_have_attribute("data-mode", "range")
block.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("data-mode", "point")
def test_widgets_initialize_inside_htmx_swapped_content(
authenticated_page: Page, live_server
):
"""Widgets arriving via an htmx swap initialize without a page load.
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
swapped-in slider must toggle exactly once, proving the htmx:load half of
onSwap and the once-per-element guard."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
page.evaluate(
"htmx.ajax('GET', window.location.pathname, "
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
)
# The swapped-in bar arrives collapsed again; opening it proves the swap
# happened and the fresh DOM is in place.
open_filter_bar(page)
widget = status_filter_widget(page)
widget.locator("[data-search-select-search]").click()
expect(widget.locator("[data-search-select-options]")).to_be_visible()
block = page.locator(".range-slider-block").first
slider = block.locator(".range-slider")
expect(slider).to_have_attribute("data-mode", "range")
block.locator(".range-mode-toggle").click()
expect(slider).to_have_attribute("data-mode", "point")
def test_add_purchase_type_toggles_disabled_fields(
authenticated_page: Page, live_server
):
"""add_purchase.js disables name/related-purchase while type is "game"
and re-enables them for other types."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
name_input = page.locator("#id_name")
expect(name_input).to_be_disabled()
page.select_option("#id_type", "dlc")
expect(name_input).to_be_enabled()
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
+18 -5
View File
@@ -6,6 +6,7 @@ from common.components import (
DEFAULT_PREFETCH, DEFAULT_PREFETCH,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
render,
searchselect_selected, searchselect_selected,
) )
from common.components.primitives import Checkbox from common.components.primitives import Checkbox
@@ -28,23 +29,32 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class PrimitiveCheckboxWidget(forms.CheckboxInput): class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component.""" """Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs) final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value) checked = self.check_test(value)
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")] attributes = [
(k, str(v))
for k, v in final_attrs.items()
if k not in ("type", "name", "value", "checked")
]
# Django uses boolean values differently for checkboxes, we omit value if empty # Django uses boolean values differently for checkboxes, we omit value if empty
return str(Checkbox( # render() returns a safe string (Django widgets must not be autoescaped).
return render(
Checkbox(
name=name, name=name,
label=None, label=None,
checked=checked, checked=checked,
value=str(value) if value else "1", value=str(value) if value else "1",
attributes=attributes attributes=attributes,
)) )
)
class PrimitiveWidgetsMixin: class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields.""" """Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field_name, field in self.fields.items(): for field_name, field in self.fields.items():
@@ -130,7 +140,9 @@ class SearchSelectWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver) selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus")) autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect( # Django widgets must return a safe string; the component is a node.
return render(
SearchSelect(
name=name, name=name,
selected=selected, selected=selected,
options=None, options=None,
@@ -144,6 +156,7 @@ class SearchSelectWidget(forms.Widget):
id=(attrs or {}).get("id", ""), id=(attrs or {}).get("id", ""),
autofocus=autofocus, autofocus=autofocus,
) )
)
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
return data.get(name) return data.get(name)
+46
View File
@@ -1952,9 +1952,17 @@
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
} }
.rounded-l-lg {
border-top-left-radius: var(--radius-lg);
border-bottom-left-radius: var(--radius-lg);
}
.rounded-tl-none { .rounded-tl-none {
border-top-left-radius: 0; border-top-left-radius: 0;
} }
.rounded-r-lg {
border-top-right-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
}
.rounded-tr-md { .rounded-tr-md {
border-top-right-radius: var(--radius-md); border-top-right-radius: var(--radius-md);
} }
@@ -2055,6 +2063,12 @@
.border-blue-200 { .border-blue-200 {
border-color: var(--color-blue-200); border-color: var(--color-blue-200);
} }
.border-blue-600 {
border-color: var(--color-blue-600);
}
.border-blue-700 {
border-color: var(--color-blue-700);
}
.border-brand { .border-brand {
border-color: var(--color-brand); border-color: var(--color-brand);
} }
@@ -2185,6 +2199,9 @@
.bg-gray-100 { .bg-gray-100 {
background-color: var(--color-gray-100); background-color: var(--color-gray-100);
} }
.bg-gray-200 {
background-color: var(--color-gray-200);
}
.bg-gray-400 { .bg-gray-400 {
background-color: var(--color-gray-400); background-color: var(--color-gray-400);
} }
@@ -2667,6 +2684,9 @@
.text-blue-500 { .text-blue-500 {
color: var(--color-blue-500); color: var(--color-blue-500);
} }
.text-blue-600 {
color: var(--color-blue-600);
}
.text-blue-800 { .text-blue-800 {
color: var(--color-blue-800); color: var(--color-blue-800);
} }
@@ -3076,6 +3096,13 @@
} }
} }
} }
.hover\:border-gray-300 {
&:hover {
@media (hover: hover) {
border-color: var(--color-gray-300);
}
}
}
.hover\:border-green-600 { .hover\:border-green-600 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3672,6 +3699,11 @@
border-color: var(--color-amber-700); border-color: var(--color-amber-700);
} }
} }
.dark\:border-blue-500 {
&:is(.dark *) {
border-color: var(--color-blue-500);
}
}
.dark\:border-blue-700 { .dark\:border-blue-700 {
&:is(.dark *) { &:is(.dark *) {
border-color: var(--color-blue-700); border-color: var(--color-blue-700);
@@ -3707,6 +3739,11 @@
border-color: var(--color-red-700); border-color: var(--color-red-700);
} }
} }
.dark\:border-transparent {
&:is(.dark *) {
border-color: transparent;
}
}
.dark\:bg-amber-900 { .dark\:bg-amber-900 {
&:is(.dark *) { &:is(.dark *) {
background-color: var(--color-amber-900); background-color: var(--color-amber-900);
@@ -4004,6 +4041,15 @@
} }
} }
} }
.dark\:hover\:text-blue-500 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-500);
}
}
}
}
.dark\:hover\:text-gray-300 { .dark\:hover\:text-gray-300 {
&:is(.dark *) { &:is(.dark *) {
&:hover { &:hover {
+5 -4
View File
@@ -1,4 +1,4 @@
import { getEl, disableElementsWhenTrue } from "./utils.js"; import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game"; const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
@@ -38,8 +38,9 @@ function setupElementHandlers() {
]); ]);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); onSwap("#id_type", (typeSelect) => {
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
setupElementHandlers(); setupElementHandlers();
typeSelect.addEventListener("change", () => {
setupElementHandlers();
});
}); });
+1
View File
@@ -0,0 +1 @@
(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l<n.length&&r<e.length;){let i=n[l],u=e[r];i in _?(_[i].test(u)&&(s+=u,l++),r++):(s+=i,l++,i===e[r]&&r++)}return s}function M(n,e=".",l,r=2){if(n==="-")return"-";if(/^\D+$/.test(n))return"9";l==null&&(l=e===","?".":",");let s=(f,g)=>{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
+5
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -4,6 +4,8 @@
* Handles form submission, preset loading/saving, and preset list rendering. * Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX plain fetch() and window.location for all interactions. * No HTMX plain fetch() and window.location for all interactions.
*/ */
import { onSwap } from "./utils.js";
(function () { (function () {
"use strict"; "use strict";
@@ -410,9 +412,8 @@
// ── Init on page load ─────────────────────────────────────────────────── // ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ── // ── Inject the search input into a filter form ──
function injectSearchInputs() { function injectSearchInput(form) {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input"); var input = document.createElement("input");
input.type = "text"; input.type = "text";
@@ -430,7 +431,6 @@
} catch (e) {} } catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling); hidden.parentNode.insertBefore(input, hidden.nextSibling);
} }
});
} }
/** /**
@@ -470,8 +470,8 @@
}); });
} }
document.addEventListener("DOMContentLoaded", function () { onSwap('[id^="filter-bar-form"]', function (form) {
injectSearchInputs(); injectSearchInput(form);
setupDeselectableRadios(); setupDeselectableRadios();
setupStringFilters(); setupStringFilters();
loadPresets(); loadPresets();
File diff suppressed because one or more lines are too long
+4 -10
View File
@@ -8,15 +8,12 @@
* Handles track-fill positioning and sync between handles and the connected * Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes). * number inputs (linked via data-target attributes).
*/ */
import { onSwap } from "./utils.js";
(function () { (function () {
"use strict"; "use strict";
function initAll(force) { function initializeSlider(slider) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var mode = slider.getAttribute("data-mode") || "range"; var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill"); var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min"); var minHandle = slider.querySelector(".range-handle-min");
@@ -227,10 +224,7 @@
// ── Initial position ── // ── Initial position ──
updateHandles(); updateHandles();
});
} }
document.addEventListener("DOMContentLoaded", initAll); onSwap(".range-slider", initializeSlider);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
})(); })();
+5 -12
View File
@@ -12,8 +12,8 @@
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their * pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar. * state into data-included / data-excluded / data-modifier for the filter bar.
* *
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with * Widgets are initialized via onSwap() (utils.js), which covers the initial
* element._searchSelectInit. * page load and every htmx-swapped fragment, once per widget.
* *
* Dynamically-added rows and pills are cloned from hidden <template> elements * Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect / * the server renders with the same Python components (Pill / SearchSelect /
@@ -21,6 +21,8 @@
* and data-* attributes so all markup and Tailwind class strings live in one * and data-* attributes so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here. * place (the Python components), never duplicated here.
*/ */
import { onSwap } from "./utils.js";
(() => { (() => {
"use strict"; "use strict";
@@ -32,14 +34,6 @@
// INCLUDES_ONLY) coexist with value pills. // INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"]; const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
const initAll = () => {
document.querySelectorAll("[data-search-select]").forEach(element => {
if (element._searchSelectInit) return;
element._searchSelectInit = true;
initWidget(element);
});
};
const initWidget = (container) => { const initWidget = (container) => {
const search = container.querySelector("[data-search-select-search]"); const search = container.querySelector("[data-search-select-search]");
const options = container.querySelector("[data-search-select-options]"); const options = container.querySelector("[data-search-select-options]");
@@ -666,6 +660,5 @@
}); });
}; };
document.addEventListener("DOMContentLoaded", initAll); onSwap("[data-search-select]", initWidget);
document.addEventListener("htmx:afterSwap", initAll);
})(); })();
+26
View File
@@ -1,3 +1,28 @@
/**
* @description Runs initializeElement once for each element matching selector,
* on initial page load and inside every htmx-swapped fragment (a port of
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
* each swapped-in element, so a single registration covers both; the WeakSet
* guarantees once-per-element initialization, replacing the old
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
* @param {string} selector
* @param {function(Element): void} initializeElement
*/
function onSwap(selector, initializeElement) {
const initialized = new WeakSet();
htmx.onLoad((swappedElement) => {
const elements = Array.from(htmx.findAll(swappedElement, selector));
if (swappedElement.matches && swappedElement.matches(selector)) {
elements.unshift(swappedElement);
}
for (const element of elements) {
if (initialized.has(element)) continue;
initialized.add(element);
initializeElement(element);
}
});
}
/** /**
* @description Formats Date to a UTC string accepted by the datetime-local input field. * @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date * @param {Date} date
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
} }
export { export {
onSwap,
toISOUTCString, toISOUTCString,
syncSelectInputUntilChanged, syncSelectInputUntilChanged,
getEl, getEl,
+9 -10
View File
@@ -3,19 +3,18 @@ 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 common.components import Component, CsrfInput, Div, Input from common.components import CsrfInput, Div, Element, Input, Node, Safe
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())), Safe(str(form.as_table())),
Tr( Tr(
children=[ children=[
Td(), Td(),
@@ -31,13 +30,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],
), ),
+2 -6
View File
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -12,7 +12,6 @@ from common.components import (
Icon, Icon,
paginated_table_content, paginated_table_content,
DeviceFilterBar, DeviceFilterBar,
ModuleScript,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
@@ -76,14 +75,11 @@ def list_devices(request: HttpRequest) -> HttpResponse:
preset_list_url=reverse("games:list_presets") + "?mode=devices", preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices", preset_save_url=reverse("games:save_preset") + "?mode=devices",
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage devices", title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+1 -2
View File
@@ -8,7 +8,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from games.models import FilterPreset from games.models import FilterPreset
@@ -40,7 +39,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
if not items: if not items:
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>'] items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')) return HttpResponse(f'<ul class="py-1">{"".join(items)}</ul>')
@login_required @login_required
+17 -19
View File
@@ -11,14 +11,15 @@ from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components import ( from common.components import (
Fragment,
H1, H1,
A, A,
AddForm, AddForm,
Button, Button,
ButtonGroup, ButtonGroup,
Component,
CsrfInput, CsrfInput,
Div, Div,
Element,
FilterBar, FilterBar,
GameStatus, GameStatus,
GameStatusSelector, GameStatusSelector,
@@ -27,9 +28,11 @@ from common.components import (
Modal, Modal,
ModuleScript, ModuleScript,
NameWithIcon, NameWithIcon,
Node,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
PurchasePrice, PurchasePrice,
Safe,
SearchField, SearchField,
SimpleTable, SimpleTable,
Ul, Ul,
@@ -145,14 +148,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage games", title="Manage games",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
@@ -203,8 +203,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"),
@@ -388,7 +388,7 @@ _PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: f
</div>""" </div>"""
def _played_row(game: Game, request: HttpRequest) -> SafeText: def _played_row(game: Game, request: HttpRequest) -> Node:
"""The 'Played N times' control with its Alpine.js dropdown.""" """The 'Played N times' control with its Alpine.js dropdown."""
replacements = { replacements = {
"@@PLAYED_COUNT@@": str(game.playevents.count()), "@@PLAYED_COUNT@@": str(game.playevents.count()),
@@ -402,7 +402,7 @@ def _played_row(game: Game, request: HttpRequest) -> SafeText:
html = _PLAYED_ROW_TEMPLATE html = _PLAYED_ROW_TEMPLATE
for token, value in replacements.items(): for token, value in replacements.items():
html = html.replace(token, value) html = html.replace(token, value)
return mark_safe(html) return Safe(html)
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText: def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -410,14 +410,12 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
popover_content=tooltip, popover_content=tooltip,
wrapped_classes="flex gap-2 items-center", wrapped_classes="flex gap-2 items-center",
id=popover_id, id=popover_id,
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)], children=[Safe(_STAT_SVGS[svg_key]), str(value)],
) )
def _meta_row( def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
label: str, value: SafeText | str, extra: SafeText | str = "" children: list[Node | str] = [
) -> SafeText:
children: list[SafeText | str] = [
Span(attributes=[("class", "uppercase")], children=[label]), Span(attributes=[("class", "uppercase")], children=[label]),
value, value,
] ]
@@ -444,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"],
) )
@@ -458,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"],
) )
@@ -567,7 +565,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
] ]
+ ( + (
[ [
mark_safe("&nbsp;"), Safe("&nbsp;"),
Popover( Popover(
popover_content="Original release year", popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl", wrapped_classes="text-slate-500 text-2xl",
+4 -11
View File
@@ -13,17 +13,14 @@ from django.urls import reverse
from django.utils.timezone import localtime from django.utils.timezone import localtime
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from common.components import ExternalScript
from common.layout import render_page from common.layout import render_page
from common.time import format_duration from common.time import format_duration
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats from games.views.stats_data import compute_stats
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker. # The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
_STATS_SCRIPTS = ExternalScript( # component, so Page() loads it automatically on the stats pages.
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
)
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -77,9 +74,7 @@ def use_custom_redirect(
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
request.session["return_path"] = request.path request.session["return_path"] = request.path
data = compute_stats(None) data = compute_stats(None)
return render_page( return render_page(request, stats_content(data), title=data["title"])
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required @login_required
@@ -93,9 +88,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("games:stats_alltime")) return HttpResponseRedirect(reverse("games:stats_alltime"))
request.session["return_path"] = request.path request.session["return_path"] = request.path
data = compute_stats(year) data = compute_stats(year)
return render_page( return render_page(request, stats_content(data), title=data["title"])
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required @login_required
+2 -6
View File
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -12,7 +12,6 @@ from common.components import (
Icon, Icon,
paginated_table_content, paginated_table_content,
PlatformFilterBar, PlatformFilterBar,
ModuleScript,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
@@ -83,14 +82,11 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
preset_list_url=reverse("games:list_presets") + "?mode=platforms", preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms", preset_save_url=reverse("games:save_preset") + "?mode=platforms",
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage platforms", title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+2 -5
View File
@@ -9,9 +9,9 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -151,14 +151,11 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
preset_list_url=reverse("games:list_presets") + "?mode=playevents", preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents", preset_save_url=reverse("games:save_preset") + "?mode=playevents",
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage play events", title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+10 -12
View File
@@ -14,18 +14,20 @@ from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from common.components import ( from common.components import (
Fragment,
A, A,
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,
@@ -129,22 +131,18 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
from common.components import ModuleScript, PurchaseFilterBar from common.components import PurchaseFilterBar
filter_bar = PurchaseFilterBar( filter_bar = PurchaseFilterBar(
filter_json=filter_json, filter_json=filter_json,
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("date_range_picker.js")
+ ModuleScript("filter_bar.js"),
) )
@@ -304,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}"),
@@ -342,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",
+10 -10
View File
@@ -11,6 +11,7 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -19,7 +20,9 @@ from common.components import (
Icon, Icon,
ModuleScript, ModuleScript,
NameWithIcon, NameWithIcon,
Node,
Popover, Popover,
Safe,
SearchField, SearchField,
SessionDeviceSelector, SessionDeviceSelector,
paginated_table_content, paginated_table_content,
@@ -176,14 +179,11 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage sessions", title="Manage sessions",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
@@ -192,17 +192,17 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", "")) return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> SafeText: def _session_fields(form) -> Fragment:
"""Manual per-field layout for the session form. """Manual per-field layout for the session form.
Mirrors the old add_session.html: each field gets its label and widget, Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons. and the timestamp fields gain a row of now/toggle/copy helper buttons.
""" """
rows: list[SafeText] = [] rows: list[Node] = []
for field in form: for field in form:
children: list[SafeText | str] = [ children: list[Node | str] = [
mark_safe(str(field.label_tag())), Safe(str(field.label_tag())),
mark_safe(str(field)), Safe(str(field)),
] ]
if field.name in ("timestamp_start", "timestamp_end"): if field.name in ("timestamp_start", "timestamp_end"):
this_side = "start" if field.name == "timestamp_start" else "end" this_side = "start" if field.name == "timestamp_start" else "end"
@@ -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 Fragment(*rows, separator="\n")
@login_required @login_required
+41 -33
View File
@@ -9,9 +9,18 @@ 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 common.components import (
A,
from common.components import A, Component, Div, GameLink, YearPicker Div,
Element,
GameLink,
Node,
Safe,
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 +28,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 +71,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
@@ -71,12 +79,12 @@ def _purchase_name(purchase) -> SafeText:
name = game_name or purchase.name name = game_name or purchase.name
link = GameLink(first_game.id, name) link = GameLink(first_game.id, name)
suffix = f" ({first_game.name} {purchase.get_type_display()})" suffix = f" ({first_game.name} {purchase.get_type_display()})"
return mark_safe(str(link) + conditional_escape(suffix)) return Safe(str(link) + conditional_escape(suffix))
name = game_name or first_game.name name = game_name or first_game.name
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 +115,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 +194,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 +221,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 +242,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 +262,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
+3 -3
View File
@@ -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],
) )
+6
View File
@@ -11,6 +11,12 @@ pkgs.mkShell {
pnpm pnpm
]; ];
# manylinux wheels with native extensions (greenlet, pulled in by
# pytest-playwright) link against libstdc++.so.6, which the nixpkgs
# Python cannot find on its default search path. Scoped to this dev
# shell only — a global LD_LIBRARY_PATH would leak into other programs.
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
shellHook = '' shellHook = ''
uv venv --clear uv venv --clear
. .venv/bin/activate . .venv/bin/activate
+171 -102
View File
@@ -2,22 +2,30 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import django import django
from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common import components from common import components
from games.models import Platform, Game, Purchase, Session 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``, ``_render_element``) are called
# 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 = components.Component( result = str(
components.Element(
tag_name="div", tag_name="div",
attributes=[("class", "test")], attributes=[("class", "test")],
children="hello", children="hello",
) )
)
self.assertEqual(result, '<div class="test">hello</div>') self.assertEqual(result, '<div class="test">hello</div>')
@@ -28,9 +36,17 @@ class ComponentCacheTest(unittest.TestCase):
components._render_element.cache_clear() components._render_element.cache_clear()
def test_identical_components_hit_cache(self): def test_identical_components_hit_cache(self):
components.Component(tag_name="div", attributes=[("class", "x")], children="hi") str(
components.Element(
tag_name="div", attributes=[("class", "x")], children="hi"
)
)
misses = components._render_element.cache_info().misses misses = components._render_element.cache_info().misses
components.Component(tag_name="div", attributes=[("class", "x")], children="hi") str(
components.Element(
tag_name="div", attributes=[("class", "x")], children="hi"
)
)
info = components._render_element.cache_info() info = components._render_element.cache_info()
self.assertEqual(info.misses, misses) # no new miss self.assertEqual(info.misses, misses) # no new miss
self.assertGreaterEqual(info.hits, 1) # served from cache self.assertGreaterEqual(info.hits, 1) # served from cache
@@ -39,10 +55,12 @@ class ComponentCacheTest(unittest.TestCase):
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096) self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
def test_safe_and_unsafe_children_do_not_collide(self): def test_safe_and_unsafe_children_do_not_collide(self):
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must """A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
render differently the cache key must keep them distinct.""" the cache key must keep them distinct."""
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")]) safe = str(
unsafe = components.Component(tag_name="span", children=["<b>x</b>"]) components.Element(tag_name="span", children=[components.Safe("<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("&lt;b&gt;x&lt;/b&gt;", unsafe) self.assertIn("&lt;b&gt;x&lt;/b&gt;", unsafe)
self.assertNotEqual(safe, unsafe) self.assertNotEqual(safe, unsafe)
@@ -114,33 +132,37 @@ class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output.""" """Test that Popover() produces deterministic HTML output."""
def test_same_popover_same_id(self): def test_same_popover_same_id(self):
r1 = components.Popover("hello", wrapped_content="hello") r1 = str(components.Popover("hello", wrapped_content="hello"))
r2 = components.Popover("hello", wrapped_content="hello") r2 = str(components.Popover("hello", wrapped_content="hello"))
self.assertEqual(r1, r2) self.assertEqual(r1, r2)
def test_different_content_different_id(self): def test_different_content_different_id(self):
r1 = components.Popover("content_a", wrapped_content="content_a") r1 = str(components.Popover("content_a", wrapped_content="content_a"))
r2 = components.Popover("content_b", wrapped_content="content_b") r2 = str(components.Popover("content_b", wrapped_content="content_b"))
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_wrapped_classes_affect_id(self): def test_wrapped_classes_affect_id(self):
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x") r1 = str(
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y") components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
)
r2 = str(
components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
)
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_wrapped_content_affects_id(self): def test_wrapped_content_affects_id(self):
r1 = components.Popover("popover", wrapped_content="wrapped_a") r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
r2 = components.Popover("popover", wrapped_content="wrapped_b") r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_popover_content_affects_id(self): def test_popover_content_affects_id(self):
r1 = components.Popover("popover_a", wrapped_content="wrapped") r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
r2 = components.Popover("popover_b", wrapped_content="wrapped") r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_full_html_deterministic(self): def test_full_html_deterministic(self):
r1 = components.Popover("hello world", wrapped_content="hello world") r1 = str(components.Popover("hello world", wrapped_content="hello world"))
r2 = components.Popover("hello world", wrapped_content="hello world") r2 = str(components.Popover("hello world", wrapped_content="hello world"))
self.assertEqual(r1.encode(), r2.encode()) self.assertEqual(r1.encode(), r2.encode())
@@ -180,26 +202,26 @@ class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly.""" """Test that component functions return SafeText and render correctly."""
def test_div_returns_safe_text(self): def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello") result = str(components.Div([("class", "x")], "hello"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_div_deterministic(self): def test_div_deterministic(self):
r1 = components.Div([("class", "x")], "hello") r1 = str(components.Div([("class", "x")], "hello"))
r2 = components.Div([("class", "x")], "hello") r2 = str(components.Div([("class", "x")], "hello"))
self.assertEqual(r1, r2) self.assertEqual(r1, r2)
self.assertIn('<div class="x">hello</div>', r1) self.assertIn('<div class="x">hello</div>', r1)
def test_div_no_args(self): def test_div_no_args(self):
result = components.Div(children="test") result = str(components.Div(children="test"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<div>test</div>", result) self.assertIn("<div>test</div>", result)
def test_a_returns_safe_text(self): def test_a_returns_safe_text(self):
result = components.A([], "link") result = str(components.A([], "link"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_a_literal_href(self): def test_a_literal_href(self):
result = components.A([], "x", href="/literal/path") result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result) self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self): def test_a_url_name_reversed(self):
@@ -208,35 +230,35 @@ class ComponentReturnTypeTest(unittest.TestCase):
with patch( with patch(
"common.components.primitives.reverse", return_value="/resolved/url" "common.components.primitives.reverse", return_value="/resolved/url"
): ):
result = components.A([], "link", url_name="some_name") result = str(components.A([], "link", url_name="some_name"))
self.assertIn('href="/resolved/url"', result) self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self): def test_a_no_url_or_href(self):
result = components.A([], "link") result = str(components.A([], "link"))
self.assertIn("<a>link</a>", result) self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result) self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self): def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
components.A(href="/path", url_name="some_name") str(components.A(href="/path", url_name="some_name"))
def test_button_returns_safe_text(self): def test_button_returns_safe_text(self):
result = components.Button([], "click") result = str(components.Button([], "click"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<button", result) self.assertIn("<button", result)
def test_button_default_colors(self): def test_button_default_colors(self):
result = components.Button([], "click") result = str(components.Button([], "click"))
self.assertIn("text-white bg-brand", result) self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self): def test_name_with_icon_no_link(self):
result = components.NameWithIcon(name="Game", linkify=False) result = str(components.NameWithIcon(name="Game", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Game", result) self.assertIn("Game", result)
self.assertNotIn("<a ", result) self.assertNotIn("<a ", result)
def test_name_with_icon_no_trailing_comma(self): def test_name_with_icon_no_trailing_comma(self):
result = components.NameWithIcon(name="Test", linkify=False) result = str(components.NameWithIcon(name="Test", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertNotIsInstance(result, tuple) self.assertNotIsInstance(result, tuple)
@@ -246,21 +268,23 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self): def test_component_output_starts_with_tag(self):
for label, html in [ for label, html in [
("A", components.A(href="/foo", children=["link"])), ("A", str(components.A(href="/foo", children=["link"]))),
("Button", components.Button([], "click")), ("Button", str(components.Button([], "click"))),
("Div", components.Div([], ["hello"])), ("Div", str(components.Div([], ["hello"]))),
("Input", components.Input()), ("Input", str(components.Input())),
("ButtonGroup", components.ButtonGroup([])), ("ButtonGroup", str(components.ButtonGroup([]))),
( (
"ButtonGroup with buttons", "ButtonGroup with buttons",
str(
components.ButtonGroup( components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}] [{"href": "/", "slot": components.Icon("edit")}]
)
), ),
), ),
("SearchField", components.SearchField()), ("SearchField", str(components.SearchField())),
("PriceConverted", components.PriceConverted(["27 CZK"])), ("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
("H1", components.H1(["Title"])), ("H1", str(components.H1(["Title"]))),
("H1 with badge", components.H1(["Title"], badge="3")), ("H1 with badge", str(components.H1(["Title"], badge="3"))),
]: ]:
with self.subTest(component=label): with self.subTest(component=label):
self.assertTrue( self.assertTrue(
@@ -269,15 +293,18 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
) )
def test_button_with_icon_children_not_escaped(self): def test_button_with_icon_children_not_escaped(self):
result = components.Button( result = str(
components.Button(
icon=True, icon=True,
size="xs", size="xs",
children=[components.Icon("play"), "LOG"], children=[components.Icon("play"), "LOG"],
) )
)
self.assertTrue(str(result).startswith("<button")) self.assertTrue(str(result).startswith("<button"))
def test_popover_with_button_children_not_escaped(self): def test_popover_with_button_children_not_escaped(self):
result = components.Popover( result = str(
components.Popover(
popover_content="test tooltip", popover_content="test tooltip",
children=[ children=[
components.Button( components.Button(
@@ -288,71 +315,90 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
), ),
], ],
) )
)
self.assertTrue(str(result).startswith("<span data-popover-target")) self.assertTrue(str(result).startswith("<span data-popover-target"))
def test_name_with_icon_output_not_escaped(self): def test_name_with_icon_output_not_escaped(self):
result = components.NameWithIcon(name="Test", linkify=False) result = str(components.NameWithIcon(name="Test", linkify=False))
self.assertTrue(str(result).startswith("<div")) self.assertTrue(str(result).startswith("<div"))
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:
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 = 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 = 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 = components.Component( result = str(
components.Element(
tag_name="div", children=["<script>alert('xss')</script>"] tag_name="div", children=["<script>alert('xss')</script>"]
) )
)
self.assertNotIn("<script>", result) self.assertNotIn("<script>", result)
self.assertIn("&lt;script&gt;", result) self.assertIn("&lt;script&gt;", result)
def test_mark_safe_children_pass_through(self): def test_safe_node_children_pass_through(self):
result = components.Component( result = str(
tag_name="div", children=[mark_safe("<span>safe</span>")] components.Element(
tag_name="div", children=[components.Safe("<span>safe</span>")]
)
) )
self.assertIn("<span>safe</span>", result) self.assertIn("<span>safe</span>", result)
def test_mark_safe_string_children_are_escaped(self):
# Trusted markup must be a Safe node; a mark_safe string is still a
# string, so it is escaped like any other text child.
result = str(
components.Element(
tag_name="div", children=[mark_safe("<span>safe</span>")]
)
)
self.assertIn("&lt;span&gt;safe&lt;/span&gt;", result)
def test_attribute_values_are_escaped(self): def test_attribute_values_are_escaped(self):
result = components.Component( result = str(
components.Element(
tag_name="div", tag_name="div",
attributes=[("data-x", 'foo"bar')], attributes=[("data-x", 'foo"bar')],
) )
)
self.assertIn("&quot;", result) self.assertIn("&quot;", result)
self.assertNotIn('"foo"bar"', result) self.assertNotIn('"foo"bar"', result)
def test_attributes_serialized_correctly(self): def test_attributes_serialized_correctly(self):
result = components.Component( result = str(
components.Element(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")] tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
) )
)
self.assertIn('class="foo"', result) self.assertIn('class="foo"', result)
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 = 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 = 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 = components.Component(tag_name="div", children="test") result = str(components.Element(tag_name="div", children="test"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
@@ -360,22 +406,22 @@ class IconTest(unittest.TestCase):
"""Test Icon() component function.""" """Test Icon() component function."""
def test_valid_icon_renders_svg(self): def test_valid_icon_renders_svg(self):
result = components.Icon("play") result = str(components.Icon("play"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result) self.assertIn("<svg", result)
self.assertIn("</svg>", result) self.assertIn("</svg>", result)
def test_unavailable_icon_falls_back(self): def test_unavailable_icon_falls_back(self):
result = components.Icon("zzz_nonexistent_platform") result = str(components.Icon("zzz_nonexistent_platform"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result) self.assertIn("<svg", result)
def test_icon_passes_attributes_to_template(self): def test_icon_passes_attributes_to_template(self):
result = components.Icon("play", attributes=[("title", "Play")]) result = str(components.Icon("play", attributes=[("title", "Play")]))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_returns_safetext(self): def test_returns_safetext(self):
result = components.Icon("delete") result = str(components.Icon("delete"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
@@ -383,18 +429,20 @@ class InputTest(unittest.TestCase):
"""Test the Input() component.""" """Test the Input() component."""
def test_input_default_type_text(self): def test_input_default_type_text(self):
result = components.Input() result = str(components.Input())
self.assertIn("<input", result) self.assertIn("<input", result)
self.assertIn('type="text"', result) self.assertIn('type="text"', result)
def test_input_custom_type(self): def test_input_custom_type(self):
result = components.Input(type="submit") result = str(components.Input(type="submit"))
self.assertIn('type="submit"', result) self.assertIn('type="submit"', result)
def test_input_attributes_merged_with_type(self): def test_input_attributes_merged_with_type(self):
result = components.Input( result = str(
components.Input(
type="email", attributes=[("id", "email"), ("class", "form-input")] type="email", attributes=[("id", "email"), ("class", "form-input")]
) )
)
self.assertIn('type="email"', result) self.assertIn('type="email"', result)
self.assertIn('id="email"', result) self.assertIn('id="email"', result)
self.assertIn('class="form-input"', result) self.assertIn('class="form-input"', result)
@@ -404,12 +452,12 @@ class PopoverTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated() component function.""" """Test PopoverTruncated() component function."""
def test_short_string_no_popover(self): def test_short_string_no_popover(self):
result = components.PopoverTruncated("hi") result = str(components.PopoverTruncated("hi"))
self.assertEqual(result, "hi") self.assertEqual(result, "hi")
def test_long_string_wrapped_in_popover(self): def test_long_string_wrapped_in_popover(self):
long_text = "a" * 100 long_text = "a" * 100
result = components.PopoverTruncated(long_text) result = str(components.PopoverTruncated(long_text))
# Should NOT equal the truncated form directly # Should NOT equal the truncated form directly
truncated = components.truncate(long_text, 30) truncated = components.truncate(long_text, 30)
self.assertNotEqual(result, truncated) self.assertNotEqual(result, truncated)
@@ -418,47 +466,55 @@ class PopoverTruncatedTest(unittest.TestCase):
def test_custom_ellipsis_used(self): def test_custom_ellipsis_used(self):
long_text = "a" * 50 long_text = "a" * 50
result = components.PopoverTruncated(long_text, ellipsis=">>") result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
# Django template escapes >> to &gt;&gt; in the wrapped_content # Django template escapes >> to &gt;&gt; in the wrapped_content
self.assertIn("&gt;&gt;", result) self.assertIn("&gt;&gt;", result)
def test_popover_if_not_truncated_flag(self): def test_popover_if_not_truncated_flag(self):
short_text = "hi" short_text = "hi"
result = components.PopoverTruncated( result = str(
short_text, popover_content="full content", popover_if_not_truncated=True components.PopoverTruncated(
short_text,
popover_content="full content",
popover_if_not_truncated=True,
)
) )
# Should be wrapped in popover even though short # Should be wrapped in popover even though short
self.assertNotEqual(result, "hi") self.assertNotEqual(result, "hi")
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
def test_popover_content_override(self): def test_popover_content_override(self):
result = components.PopoverTruncated("short", popover_content="custom popover") result = str(
components.PopoverTruncated("short", popover_content="custom popover")
)
# With popover_if_not_truncated=False (default), short text returns as-is # With popover_if_not_truncated=False (default), short text returns as-is
self.assertEqual(result, "short") self.assertEqual(result, "short")
def test_popover_content_override_with_flag(self): def test_popover_content_override_with_flag(self):
result = components.PopoverTruncated( result = str(
components.PopoverTruncated(
"short", popover_content="custom popover", popover_if_not_truncated=True "short", popover_content="custom popover", popover_if_not_truncated=True
) )
)
self.assertIn("custom popover", result) self.assertIn("custom popover", result)
def test_endpart_visible_in_output(self): def test_endpart_visible_in_output(self):
long_text = "a" * 50 long_text = "a" * 50
result = components.PopoverTruncated(long_text, endpart="...") result = str(components.PopoverTruncated(long_text, endpart="..."))
self.assertIn("...", result) self.assertIn("...", result)
def test_returns_safetext(self): def test_returns_safetext(self):
result = components.PopoverTruncated("a" * 100) result = str(components.PopoverTruncated("a" * 100))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
def test_default_length(self): def test_default_length(self):
text = "a" * 31 text = "a" * 31
result = components.PopoverTruncated(text) result = str(components.PopoverTruncated(text))
# 31 chars exceeds default length of 30, so should be truncated # 31 chars exceeds default length of 30, so should be truncated
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
def test_length_zero(self): def test_length_zero(self):
result = components.PopoverTruncated("hello", length=0) result = str(components.PopoverTruncated("hello", length=0))
# Even empty length triggers popover for any content # Even empty length triggers popover for any content
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
@@ -490,7 +546,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_linkify_with_game(self): def test_name_with_icon_linkify_with_game(self):
platform = self._create_platform(name="Steam", icon="steam") platform = self._create_platform(name="Steam", icon="steam")
game = self._create_game(platform) game = self._create_game(platform)
result = components.NameWithIcon(game=game, linkify=True) result = str(components.NameWithIcon(game=game, linkify=True))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result) self.assertIn("<a ", result)
self.assertIn("Test Game", result) self.assertIn("Test Game", result)
@@ -499,7 +555,9 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_no_linkify(self): def test_name_with_icon_no_linkify(self):
platform = self._create_platform(name="GOG", icon="gog") platform = self._create_platform(name="GOG", icon="gog")
game = self._create_game(platform) game = self._create_game(platform)
result = components.NameWithIcon(name="Test Game", game=game, linkify=False) result = str(
components.NameWithIcon(name="Test Game", game=game, linkify=False)
)
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertNotIn("<a ", result) self.assertNotIn("<a ", result)
self.assertIn("Test Game", result) self.assertIn("Test Game", result)
@@ -512,13 +570,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
timestamp_start="2025-01-01 00:00:00+00:00", timestamp_start="2025-01-01 00:00:00+00:00",
emulated=True, emulated=True,
) )
result = components.NameWithIcon(session=session, linkify=True) result = str(components.NameWithIcon(session=session, linkify=True))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result) self.assertIn("<a ", result)
self.assertIn("Emulated", result) self.assertIn("Emulated", result)
def test_name_with_icon_no_platform(self): def test_name_with_icon_no_platform(self):
result = components.NameWithIcon(name="Standalone", linkify=False) result = str(components.NameWithIcon(name="Standalone", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Standalone", result) self.assertIn("Standalone", result)
@@ -529,7 +587,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game=game, game=game,
timestamp_start="2025-01-01 00:00:00+00:00", timestamp_start="2025-01-01 00:00:00+00:00",
) )
result = components.NameWithIcon(session=session, linkify=True) result = str(components.NameWithIcon(session=session, linkify=True))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Epic Game", result) self.assertIn("Epic Game", result)
@@ -537,7 +595,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform() platform = self._create_platform()
game = self._create_game(platform) game = self._create_game(platform)
purchase = self._create_purchase([game], price=29.99) purchase = self._create_purchase([game], price=29.99)
result = components.PurchasePrice(purchase) result = str(components.PurchasePrice(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
# floatformat rounds to 1 decimal: 29.99 -> 30.0 # floatformat rounds to 1 decimal: 29.99 -> 30.0
self.assertIn("30.0", result) self.assertIn("30.0", result)
@@ -548,7 +606,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform(icon="steam") platform = self._create_platform(icon="steam")
game = self._create_game(platform, name="Single Game") game = self._create_game(platform, name="Single Game")
purchase = self._create_purchase([game], price=14.99) purchase = self._create_purchase([game], price=14.99)
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Single Game", result) self.assertIn("Single Game", result)
self.assertIn("<a ", result) self.assertIn("<a ", result)
@@ -559,7 +617,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Game One") game1 = self._create_game(platform, name="Game One")
game2 = self._create_game(platform, name="Game Two") game2 = self._create_game(platform, name="Game Two")
purchase = self._create_purchase([game1, game2], price=24.99) purchase = self._create_purchase([game1, game2], price=24.99)
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("2 games", result) self.assertIn("2 games", result)
self.assertIn("<a ", result) self.assertIn("<a ", result)
@@ -575,7 +633,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
) )
purchase.name = "Bundle" purchase.name = "Bundle"
purchase.save() purchase.save()
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Bundle", result) self.assertIn("Bundle", result)
@@ -584,7 +642,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Alpha") game1 = self._create_game(platform, name="Alpha")
game2 = self._create_game(platform, name="Beta") game2 = self._create_game(platform, name="Beta")
purchase = self._create_purchase([game1, game2], price=19.99) purchase = self._create_purchase([game1, game2], price=19.99)
result = components.LinkedPurchase(purchase) result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Alpha", result) self.assertIn("Alpha", result)
self.assertIn("Beta", result) self.assertIn("Beta", result)
@@ -595,18 +653,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
def test_endpart_shorter_than_length(self): def test_endpart_shorter_than_length(self):
text = "a" * 50 text = "a" * 50
result = components.PopoverTruncated(text, length=10, endpart="x") result = str(components.PopoverTruncated(text, length=10, endpart="x"))
# endpart=x takes 1 char, so content gets truncated at 9 chars # endpart=x takes 1 char, so content gets truncated at 9 chars
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
self.assertIn("x", result) self.assertIn("x", result)
def test_no_truncation_no_ellipsis(self): def test_no_truncation_no_ellipsis(self):
result = components.PopoverTruncated("short text") result = str(components.PopoverTruncated("short text"))
self.assertEqual(result, "short text") self.assertEqual(result, "short text")
def test_custom_length(self): def test_custom_length(self):
text = "hello world" text = "hello world"
result = components.PopoverTruncated(text, length=6) result = str(components.PopoverTruncated(text, length=6))
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
@@ -620,12 +678,14 @@ class NameWithIconPlatformTest(django.test.TestCase):
cls.game = Game.objects.create(name="Zelda", platform=cls.platform) cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
def test_name_with_icon_shows_platform_icon(self): def test_name_with_icon_shows_platform_icon(self):
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True) result = str(
components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
)
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Zelda", result) self.assertIn("Zelda", result)
def test_name_with_icon_no_game_id_no_platform(self): def test_name_with_icon_no_game_id_no_platform(self):
result = components.NameWithIcon(name="Unknown Game", linkify=False) result = str(components.NameWithIcon(name="Unknown Game", linkify=False))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Unknown Game", result) self.assertIn("Unknown Game", result)
@@ -749,11 +809,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_renders_list_rows(self): def test_simple_table_renders_list_rows(self):
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>.""" """Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
result = str( result = str(
str(
components.SimpleTable( components.SimpleTable(
columns=["Game", "Started", "Ended"], columns=["Game", "Started", "Ended"],
rows=[["Game1", "2025-01-01", "2025-03-01"]], rows=[["Game1", "2025-01-01", "2025-03-01"]],
) )
) )
)
tbody = self._tbody(result) tbody = self._tbody(result)
self.assertIn("<tr", tbody) self.assertIn("<tr", tbody)
self.assertIn("Game1", tbody) self.assertIn("Game1", tbody)
@@ -774,11 +836,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_multiple_rows(self): def test_simple_table_multiple_rows(self):
"""Verify multiple rows all render.""" """Verify multiple rows all render."""
result = str( result = str(
str(
components.SimpleTable( components.SimpleTable(
columns=["Game", "Started"], columns=["Game", "Started"],
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]], rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
) )
) )
)
tbody = self._tbody(result) tbody = self._tbody(result)
self.assertIn("GameA", tbody) self.assertIn("GameA", tbody)
self.assertIn("GameB", tbody) self.assertIn("GameB", tbody)
@@ -786,13 +850,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_header_action_as_caption(self): def test_simple_table_header_action_as_caption(self):
"""Verify header_action renders inside <caption>.""" """Verify header_action renders inside <caption>."""
from django.utils.safestring import mark_safe
result = str( result = str(
str(
components.SimpleTable( components.SimpleTable(
columns=["Game", "Started"], columns=["Game", "Started"],
rows=[["Game1", "2025-01-01"]], rows=[["Game1", "2025-01-01"]],
header_action=mark_safe('<a href="/add">Add</a>'), header_action=components.Safe('<a href="/add">Add</a>'),
)
) )
) )
self.assertIn("<caption", result) self.assertIn("<caption", result)
@@ -802,6 +866,7 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_dict_rows_with_cell_data(self): def test_simple_table_dict_rows_with_cell_data(self):
"""Verify dict-style rows with row_id and cell_data render correctly.""" """Verify dict-style rows with row_id and cell_data render correctly."""
result = str( result = str(
str(
components.SimpleTable( components.SimpleTable(
columns=["Name", "Date"], columns=["Name", "Date"],
rows=[ rows=[
@@ -813,6 +878,7 @@ class SimpleTableRenderingTest(unittest.TestCase):
], ],
) )
) )
)
tbody = self._tbody(result) tbody = self._tbody(result)
self.assertIn('id="session-row-1"', tbody) self.assertIn('id="session-row-1"', tbody)
self.assertIn("device-changed", tbody) self.assertIn("device-changed", tbody)
@@ -821,15 +887,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
self.assertIn("2025-01-01", tbody) self.assertIn("2025-01-01", tbody)
from django.test import SimpleTestCase
from common.components.primitives import Checkbox, Radio
class ComponentPrimitivesTest(SimpleTestCase): class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self): def test_checkbox_primitive(self):
html = Checkbox( html = str(
components.Checkbox(
name="test-check", label="Accept Terms", checked=True, value="yes" name="test-check", label="Accept Terms", checked=True, value="yes"
) )
)
self.assertIn('type="checkbox"', html) self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html) self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html) self.assertIn('value="yes"', html)
@@ -837,14 +901,18 @@ class ComponentPrimitivesTest(SimpleTestCase):
self.assertIn("Accept Terms", html) self.assertIn("Accept Terms", html)
def test_checkbox_headless(self): def test_checkbox_headless(self):
html = Checkbox(name="test-headless", label=None, checked=True) html = str(components.Checkbox(name="test-headless", label=None, checked=True))
self.assertNotIn("<label", html) self.assertNotIn("<label", html)
self.assertIn("<input", html) self.assertIn("<input", html)
self.assertIn('type="checkbox"', html) self.assertIn('type="checkbox"', html)
self.assertIn('name="test-headless"', html) self.assertIn('name="test-headless"', html)
def test_radio_primitive(self): def test_radio_primitive(self):
html = Radio(name="test-radio", label="Option A", checked=False, value="A") html = str(
components.Radio(
name="test-radio", label="Option A", checked=False, value="A"
)
)
self.assertIn('type="radio"', html) self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html) self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html) self.assertIn('value="A"', html)
@@ -867,6 +935,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)
+178
View File
@@ -0,0 +1,178 @@
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
These cover the new machinery directly: rendering, escaping, media bubbling.
"""
import unittest
from django.utils.safestring import mark_safe
from common.components import (
BaseComponent,
Element,
Fragment,
Media,
Node,
Safe,
collect_media,
render,
)
class ElementRenderTest(unittest.TestCase):
def test_renders_tag_attrs_children(self):
element = Element("div", [("class", "test")], "hello")
self.assertEqual(render(element), '<div class="test">hello</div>')
def test_plain_string_children_escaped(self):
self.assertEqual(
render(Element("span", children=["<b>"])), "<span>&lt;b&gt;</span>"
)
def test_safe_node_child_passes_through(self):
self.assertEqual(
render(Element("span", children=[Safe("<b>x</b>")])),
"<span><b>x</b></span>",
)
def test_safetext_child_is_escaped(self):
# A string child is always escaped — even a mark_safe/SafeText one.
# Trusted markup must be a Safe node, not a safe string.
self.assertEqual(
render(Element("span", children=[mark_safe("<b>x</b>")])),
"<span>&lt;b&gt;x&lt;/b&gt;</span>",
)
def test_node_children_render_safely(self):
inner = Element("b", children=["x"])
self.assertEqual(
render(Element("span", children=[inner])), "<span><b>x</b></span>"
)
class SafeAndFragmentTest(unittest.TestCase):
def test_safe_passes_html_through(self):
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
def test_fragment_concatenates(self):
frag = Fragment(
Element("span", children=["a"]), Element("span", children=["b"])
)
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
def test_fragment_skips_empty_children(self):
frag = Fragment("", None, Element("span", children=["a"]))
self.assertEqual(render(frag), "<span>a</span>")
def test_fragment_escapes_plain_strings(self):
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "&lt;x&gt;<y>")
class MediaTest(unittest.TestCase):
def test_merge_dedups_preserving_order(self):
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
def test_external_kept_separate(self):
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
self.assertEqual(merged.js, ("a.js",))
self.assertEqual(merged.js_external, ("umd.js",))
def test_sum_with_radd(self):
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
self.assertEqual(merged.js, ("a.js", "b.js"))
def test_falsy_when_empty(self):
self.assertFalse(Media())
self.assertTrue(Media(js=["a.js"]))
class MediaCollectionTest(unittest.TestCase):
def test_bubbles_through_element_children(self):
class Widget(BaseComponent):
media = Media(js=["widget.js"])
def render(self) -> Node:
return Element("div", children=["x"])
tree = Element("section", children=[Element("div", children=[Widget()])])
self.assertEqual(collect_media(tree).js, ("widget.js",))
def test_bubbles_through_fragment(self):
class Widget(BaseComponent):
media = Media(js=["w.js"])
def render(self) -> Node:
return Element("div")
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
def test_component_merges_own_and_subtree_media(self):
class Inner(BaseComponent):
media = Media(js=["inner.js"])
def render(self) -> Node:
return Element("span")
class Outer(BaseComponent):
media = Media(js=["outer.js"])
def render(self) -> Node:
return Element("div", children=[Inner()])
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
def test_bare_string_has_no_media(self):
self.assertFalse(collect_media("just a string"))
class RealComponentMediaTest(unittest.TestCase):
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
def test_search_select_declares_its_script(self):
from common.components import SearchSelect
self.assertEqual(
collect_media(SearchSelect(name="games")).js, ("search_select.js",)
)
def test_filter_select_declares_its_script(self):
from common.components import FilterSelect
self.assertIn(
"search_select.js", collect_media(FilterSelect(field_name="type")).js
)
def test_date_range_picker_declares_its_script(self):
from common.components import DateRangePicker
media = collect_media(
DateRangePicker(label="Played", input_name_prefix="played")
)
self.assertEqual(media.js, ("date_range_picker.js",))
def test_range_slider_declares_its_script(self):
from common.components.filters import RangeSlider
media = collect_media(
RangeSlider(
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
)
)
self.assertEqual(media.js, ("range_slider.js",))
def test_filter_bar_collects_chrome_and_widget_media(self):
"""A FilterBar's media merges its own chrome script with the scripts that
bubble up from the FilterSelect and RangeSlider widgets it contains
exactly the set the view used to thread by hand. (FilterBar wraps its DB
aggregates in try/except, so it builds without a database.)"""
from common.components import FilterBar
media = collect_media(FilterBar())
self.assertIn("filter_bar.js", media.js)
self.assertIn("search_select.js", media.js)
self.assertIn("range_slider.js", media.js)
if __name__ == "__main__":
unittest.main()
+18 -5
View File
@@ -57,6 +57,22 @@ class RenderedPagesTest(TestCase):
marker, html, f"Found double-escaped markup ({marker!r}) in output" marker, html, f"Found double-escaped markup ({marker!r}) in output"
) )
# --- scripts auto-collected from component media (Phase 4) ---------------
def test_list_page_auto_loads_widget_scripts(self):
"""The games list view passes no scripts= argument; the filter bar's
components declare their JS and Page() collects it."""
html = self.get("games:list_games").content.decode()
self.assertIn("js/filter_bar.js", html)
self.assertIn("js/search_select.js", html)
self.assertIn("js/range_slider.js", html)
def test_stats_page_auto_loads_datepicker(self):
"""YearPicker declares the datepicker UMD bundle as media; the stats
view no longer hoists it by hand."""
html = self.get("games:stats_alltime").content.decode()
self.assertIn("js/datepicker.umd.js", html)
# --- layout wrapper ------------------------------------------------------ # --- layout wrapper ------------------------------------------------------
def test_page_layout_wrapper(self): def test_page_layout_wrapper(self):
@@ -395,15 +411,12 @@ class PurchaseListDateFilterTest(TestCase):
html, html,
) )
self.assertIn( self.assertIn(
'name="filter-date-purchased-max" id="filter-date-purchased-max" ' 'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
'value=""',
html, html,
) )
def test_date_refunded_not_null(self): def test_date_refunded_not_null(self):
response = self._get( response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
html = response.content.decode() html = response.content.decode()
self.assertNotIn("EARLY-MARKER", html) self.assertNotIn("EARLY-MARKER", html)
+63 -44
View File
@@ -7,69 +7,76 @@ 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, Pill, SearchSelect
from games.models import Game, Platform from games.models import Game, Platform
# These components are lazy nodes; the tests below assert on rendered HTML, so
# each call is wrapped in ``str(...)`` (``Node.__str__`` returns a ``SafeText``,
# which keeps the ``assertIsInstance(..., SafeText)`` checks meaningful and the
# string assertions working).
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(str(Pill("hi")), SafeText)
def test_plain_pill_has_data_pill_no_remove(self): def test_plain_pill_has_data_pill_no_remove(self):
html = Pill("hi") html = str(Pill("hi"))
self.assertIn("data-pill", html) self.assertIn("data-pill", html)
self.assertNotIn("data-pill-remove", html) self.assertNotIn("data-pill-remove", html)
def test_removable_adds_remove_button(self): def test_removable_adds_remove_button(self):
html = Pill("hi", removable=True) html = str(Pill("hi", removable=True))
self.assertIn("data-pill-remove", html) self.assertIn("data-pill-remove", html)
self.assertIn('aria-label="Remove"', html) self.assertIn('aria-label="Remove"', html)
def test_value_becomes_data_value(self): def test_value_becomes_data_value(self):
html = Pill("hi", value="42") html = str(Pill("hi", value="42"))
self.assertIn('data-value="42"', html) self.assertIn('data-value="42"', html)
def test_no_value_omits_data_value(self): def test_no_value_omits_data_value(self):
self.assertNotIn("data-value", Pill("hi")) self.assertNotIn("data-value", str(Pill("hi")))
def test_label_is_escaped(self): def test_label_is_escaped(self):
html = Pill("<b>x</b>") html = str(Pill("<b>x</b>"))
self.assertIn("&lt;b&gt;", html) self.assertIn("&lt;b&gt;", html)
self.assertNotIn("<b>x</b>", html) self.assertNotIn("<b>x</b>", html)
def test_extra_data_attributes(self): def test_extra_data_attributes(self):
html = Pill("hi", attributes=[("data-platform", "3")]) html = str(Pill("hi", attributes=[("data-platform", "3")]))
self.assertIn('data-platform="3"', html) self.assertIn('data-platform="3"', html)
class SearchSelectComponentTest(unittest.TestCase): class SearchSelectComponentTest(unittest.TestCase):
def test_returns_safetext(self): def test_returns_safetext(self):
self.assertIsInstance(SearchSelect(name="games"), SafeText) self.assertIsInstance(str(SearchSelect(name="games")), SafeText)
def test_empty_options_renders_no_results_scaffold(self): def test_empty_options_renders_no_results_scaffold(self):
html = SearchSelect(name="games") html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-no-results", html) self.assertIn("data-search-select-no-results", html)
self.assertIn("No results", html) self.assertIn("No results", html)
def test_outer_container_carries_config(self): def test_outer_container_carries_config(self):
html = SearchSelect( html = str(
SearchSelect(
name="games", search_url="/api/games/search", multi_select=True name="games", search_url="/api/games/search", multi_select=True
) )
)
self.assertIn("data-search-select", html) self.assertIn("data-search-select", html)
self.assertIn('data-name="games"', html) self.assertIn('data-name="games"', html)
self.assertIn('data-search-url="/api/games/search"', html) self.assertIn('data-search-url="/api/games/search"', html)
self.assertIn('data-multi="true"', html) self.assertIn('data-multi="true"', html)
def test_multi_selected_renders_pills_and_hidden_inputs(self): def test_multi_selected_renders_pills_and_hidden_inputs(self):
html = SearchSelect( html = str(
SearchSelect(
name="games", name="games",
multi_select=True, multi_select=True,
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
) )
)
self.assertIn("data-pill", html) self.assertIn("data-pill", html)
self.assertIn('<input name="games" value="7" type="hidden">', html) self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('data-platform="2"', html) self.assertIn('data-platform="2"', html)
@@ -78,10 +85,12 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertEqual(html.count(' name="games"'), 1) self.assertEqual(html.count(' name="games"'), 1)
def test_single_selected_has_no_pill_and_value_in_search_box(self): def test_single_selected_has_no_pill_and_value_in_search_box(self):
html = SearchSelect( html = str(
SearchSelect(
name="games", name="games",
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
) )
)
# single-select renders no pill — the label lives in the search box # single-select renders no pill — the label lives in the search box
self.assertNotIn("data-pill", html) self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html) self.assertIn('value="Game A"', html)
@@ -90,21 +99,23 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertEqual(html.count(' name="games"'), 1) self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self): def test_search_box_has_no_name(self):
html = SearchSelect(name="games") html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-search", html) self.assertIn("data-search-select-search", html)
# container exposes data-name, never a submittable name on the search box # container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0) self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self): def test_tuple_options_are_normalized(self):
html = SearchSelect(name="t", options=[("1", "One")]) html = str(SearchSelect(name="t", options=[("1", "One")]))
self.assertIn('data-search-select-option=""', html) self.assertIn('data-search-select-option=""', html)
self.assertIn('data-value="1"', html) self.assertIn('data-value="1"', html)
self.assertIn("One", html) self.assertIn("One", html)
def test_options_omitted_when_search_url_set(self): def test_options_omitted_when_search_url_set(self):
html = SearchSelect( html = str(
SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search" name="t", options=[("1", "One")], search_url="/api/games/search"
) )
)
# No pre-rendered rows in the live panel; the row prototype lives only in # No pre-rendered rows in the live panel; the row prototype lives only in
# the cloneable <template>. # the cloneable <template>.
panel = html.split("data-search-select-template")[0] panel = html.split("data-search-select-template")[0]
@@ -114,7 +125,9 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_templates_carry_label_slot_for_js_cloning(self): def test_templates_carry_label_slot_for_js_cloning(self):
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS # The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
# only fills text — classes/structure stay server-side. # only fills text — classes/structure stay server-side.
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True) html = str(
SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
)
self.assertIn('data-search-select-template="row"', html) self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-template="pill"', html) self.assertIn('data-search-select-template="pill"', html)
self.assertIn("data-search-select-label", html) self.assertIn("data-search-select-label", html)
@@ -122,7 +135,7 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_shell_region_order_pills_search_options(self): def test_shell_region_order_pills_search_options(self):
# The shared shell assembles the three regions in a fixed order; option # The shared shell assembles the three regions in a fixed order; option
# rows precede the trailing no-results node inside the options panel. # rows precede the trailing no-results node inside the options panel.
html = SearchSelect(name="t", options=[("1", "One")]) html = str(SearchSelect(name="t", options=[("1", "One")]))
pills = html.index("data-search-select-pills") pills = html.index("data-search-select-pills")
search = html.index("data-search-select-search") search = html.index("data-search-select-search")
options = html.index("data-search-select-options") options = html.index("data-search-select-options")
@@ -135,11 +148,11 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_prefetch_attribute_and_defaults(self): def test_prefetch_attribute_and_defaults(self):
# Default prefetch is 0 in SearchSelect # Default prefetch is 0 in SearchSelect
html_default = SearchSelect(name="t") html_default = str(SearchSelect(name="t"))
self.assertIn('data-prefetch="0"', html_default) self.assertIn('data-prefetch="0"', html_default)
# Custom prefetch is rendered # Custom prefetch is rendered
html_custom = SearchSelect(name="t", prefetch=42) html_custom = str(SearchSelect(name="t", prefetch=42))
self.assertIn('data-prefetch="42"', html_custom) self.assertIn('data-prefetch="42"', html_custom)
@@ -147,10 +160,10 @@ class FilterSelectComponentTest(unittest.TestCase):
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")] MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
def test_returns_safetext(self): def test_returns_safetext(self):
self.assertIsInstance(FilterSelect(field_name="type"), SafeText) self.assertIsInstance(str(FilterSelect(field_name="type")), SafeText)
def test_is_filter_mode_on_shared_shell(self): def test_is_filter_mode_on_shared_shell(self):
html = FilterSelect(field_name="type") html = str(FilterSelect(field_name="type"))
# Reuses the SearchSelect shell (data-search-select) but flags filter mode. # Reuses the SearchSelect shell (data-search-select) but flags filter mode.
self.assertIn("data-search-select", html) self.assertIn("data-search-select", html)
self.assertIn('data-search-select-mode="filter"', html) self.assertIn('data-search-select-mode="filter"', html)
@@ -159,18 +172,20 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertEqual(html.count(' name="type"'), 0) self.assertEqual(html.count(' name="type"'), 0)
def test_value_rows_have_include_exclude_buttons(self): def test_value_rows_have_include_exclude_buttons(self):
html = FilterSelect(field_name="type", options=[("g", "Game")]) html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
self.assertIn('data-search-select-action="include"', html) self.assertIn('data-search-select-action="include"', html)
self.assertIn('data-search-select-action="exclude"', html) self.assertIn('data-search-select-action="exclude"', html)
self.assertIn('data-value="g"', html) self.assertIn('data-value="g"', html)
def test_included_renders_check_pill_excluded_renders_cross_pill(self): def test_included_renders_check_pill_excluded_renders_cross_pill(self):
html = FilterSelect( html = str(
FilterSelect(
field_name="platform", field_name="platform",
options=[("1", "Steam"), ("2", "GOG")], options=[("1", "Steam"), ("2", "GOG")],
included=[("1", "Steam")], included=[("1", "Steam")],
excluded=[("2", "GOG")], excluded=[("2", "GOG")],
) )
)
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗ # Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
# symbol is a sibling text node. # symbol is a sibling text node.
self.assertIn('data-search-select-type="include"', html) self.assertIn('data-search-select-type="include"', html)
@@ -182,7 +197,7 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn("line-through", html) # excluded pill styling self.assertIn("line-through", html) # excluded pill styling
def test_modifier_options_render_pinned_rows(self): def test_modifier_options_render_pinned_rows(self):
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS) html = str(FilterSelect(field_name="platform", modifier_options=self.MODIFIERS))
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option, # Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
# so the text filter leaves them visible. # so the text filter leaves them visible.
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
@@ -191,28 +206,30 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_modifier_pill_coexists_with_value_pills(self): def test_modifier_pill_coexists_with_value_pills(self):
"""Modifier and value pills both render server-side; the JS handles """Modifier and value pills both render server-side; the JS handles
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS).""" mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
html = FilterSelect( html = str(
FilterSelect(
field_name="platform", field_name="platform",
options=[("1", "Steam")], options=[("1", "Steam")],
included=[("1", "Steam")], included=[("1", "Steam")],
modifier="IS_NULL", modifier="IS_NULL",
modifier_options=self.MODIFIERS, modifier_options=self.MODIFIERS,
) )
)
# 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):
html = FilterSelect( html = str(
FilterSelect(
field_name="game", field_name="game",
search_url="/api/games/search", search_url="/api/games/search",
prefetch=20, prefetch=20,
modifier_options=self.MODIFIERS, modifier_options=self.MODIFIERS,
) )
)
# No value rows in the live panel (they're fetched); the row prototype # No value rows in the live panel (they're fetched); the row prototype
# lives only in a <template>. # lives only in a <template>.
panel = html.split("data-search-select-template")[0] panel = html.split("data-search-select-template")[0]
@@ -225,11 +242,13 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_search_url_pills_use_resolved_labels(self): def test_search_url_pills_use_resolved_labels(self):
# A selected value outside the fetched window still shows its label. # A selected value outside the fetched window still shows its label.
html = FilterSelect( html = str(
FilterSelect(
field_name="game", field_name="game",
search_url="/api/games/search", search_url="/api/games/search",
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}], excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
) )
)
self.assertIn(">Obscure Game</span>", html) self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html) self.assertIn('data-value="4172"', html)
@@ -241,7 +260,8 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_m2m_modifiers_render_as_option_rows(self): def test_m2m_modifiers_render_as_option_rows(self):
"""M2M modifiers (All)/(Only) render as modifier-option rows in the """M2M modifiers (All)/(Only) render as modifier-option rows in the
dropdown, not as a separate <select>.""" dropdown, not as a separate <select>."""
html = FilterSelect( html = str(
FilterSelect(
field_name="games", field_name="games",
modifier_options=[ modifier_options=[
("NOT_NULL", "(Any)"), ("NOT_NULL", "(Any)"),
@@ -250,22 +270,18 @@ class FilterSelectComponentTest(unittest.TestCase):
("INCLUDES_ONLY", "(Only)"), ("INCLUDES_ONLY", "(Only)"),
], ],
) )
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ALL"', html
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ONLY"', html
)
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
) )
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
# No legacy match-mode <select>. # No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html) self.assertNotIn("data-search-select-match", html)
def test_active_modifier_renders_pill(self): def test_active_modifier_renders_pill(self):
"""When modifier is INCLUDES_ALL, the modifier pill renders with the """When modifier is INCLUDES_ALL, the modifier pill renders with the
(All) label alongside any value pills.""" (All) label alongside any value pills."""
html = FilterSelect( html = str(
FilterSelect(
field_name="games", field_name="games",
modifier="INCLUDES_ALL", modifier="INCLUDES_ALL",
modifier_options=[ modifier_options=[
@@ -276,6 +292,7 @@ class FilterSelectComponentTest(unittest.TestCase):
], ],
included=[{"value": 5, "label": "Hollow Knight", "data": {}}], included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
) )
)
self.assertIn('data-modifier="INCLUDES_ALL"', html) self.assertIn('data-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html) self.assertIn("(All)", html)
self.assertIn("Hollow Knight", html) self.assertIn("Hollow Knight", html)
@@ -283,11 +300,13 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_presence_only_modifiers_no_m2m_rows(self): def test_presence_only_modifiers_no_m2m_rows(self):
"""When modifier_options only has presence entries, no M2M rows appear.""" """When modifier_options only has presence entries, no M2M rows appear."""
html = FilterSelect( html = str(
FilterSelect(
field_name="status", field_name="status",
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")], modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
options=[("f", "Finished")], options=[("f", "Finished")],
) )
)
self.assertNotIn("INCLUDES_ALL", html) self.assertNotIn("INCLUDES_ALL", html)
self.assertNotIn("INCLUDES_ONLY", html) self.assertNotIn("INCLUDES_ONLY", html)