Merge pull request #15 from KucharczykL/claude/kind-gauss-vj2wyp
Lazy node-tree component system + onSwap widget lifecycle
This commit is contained in:
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
|
||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
e2e/ — Playwright browser tests (run via `make test-e2e`)
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
docs/ — Additional documentation
|
||||
```
|
||||
@@ -57,12 +58,12 @@ docs/ — Additional documentation
|
||||
|
||||
### 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.
|
||||
- **`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()`
|
||||
- **`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. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
|
||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||
- **`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
|
||||
|
||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
||||
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **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`
|
||||
- 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/`:
|
||||
- `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)
|
||||
- `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
|
||||
|
||||
@@ -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"`).
|
||||
|
||||
**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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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`.
|
||||
|
||||
@@ -5,11 +5,18 @@ re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.components.core import (
|
||||
Component,
|
||||
BaseComponent,
|
||||
Element,
|
||||
Fragment,
|
||||
HTMLAttribute,
|
||||
HTMLTag,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
_render_element,
|
||||
collect_media,
|
||||
randomid,
|
||||
render,
|
||||
)
|
||||
from common.components.date_range_picker import (
|
||||
DateRangeCalendar,
|
||||
@@ -59,11 +66,13 @@ from common.components.primitives import (
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
StaticScript,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
Td,
|
||||
Template,
|
||||
Th,
|
||||
Tr,
|
||||
Ul,
|
||||
YearPicker,
|
||||
@@ -81,7 +90,14 @@ from common.utils import truncate
|
||||
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"Component",
|
||||
"BaseComponent",
|
||||
"Element",
|
||||
"Fragment",
|
||||
"Media",
|
||||
"Node",
|
||||
"Safe",
|
||||
"collect_media",
|
||||
"render",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
@@ -112,7 +128,13 @@ __all__ = [
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"StaticScript",
|
||||
"Label",
|
||||
"Li",
|
||||
"Td",
|
||||
"Th",
|
||||
"Tr",
|
||||
"Ul",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
|
||||
+296
-27
@@ -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
|
||||
from collections.abc import Sequence
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
@@ -10,24 +24,181 @@ from django.utils.safestring import SafeText, mark_safe
|
||||
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
|
||||
|
||||
|
||||
# ── 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)
|
||||
def _render_element(
|
||||
tag_name: str,
|
||||
attrs_key: tuple[tuple[str, str], ...],
|
||||
children_key: tuple[tuple[str, bool], ...],
|
||||
) -> 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
|
||||
elements are rendered once. `attrs_key` is (name, stringified value) pairs
|
||||
(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.
|
||||
``attrs_key`` is (name, stringified value) pairs (values always escaped);
|
||||
``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
|
||||
"""
|
||||
children_blob = "\n".join(
|
||||
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}>"
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
"""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
|
||||
`_render_element`."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||
class Element(Node):
|
||||
"""Any HTML element: a tag name, attributes and children.
|
||||
|
||||
Children may be other nodes, ``SafeText``, or plain strings (escaped).
|
||||
Rendering goes through the memoized :func:`_render_element`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag_name: str,
|
||||
attributes: Attributes | None = None,
|
||||
children: "Children | Node" = None,
|
||||
) -> None:
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
self.tag_name = tag_name
|
||||
self.attributes = attributes or []
|
||||
if children is None:
|
||||
children = []
|
||||
elif isinstance(children, (str, Node)):
|
||||
children = [children]
|
||||
self.children = children
|
||||
|
||||
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:
|
||||
|
||||
@@ -17,12 +17,14 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
||||
``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.time import DatePartSpec, date_parts
|
||||
|
||||
# Wired by date_range_picker.js.
|
||||
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
|
||||
|
||||
_FIELD_CONTAINER_CLASS = (
|
||||
"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 "
|
||||
@@ -101,7 +103,7 @@ def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str
|
||||
|
||||
def _segment_input(
|
||||
*, part: DatePartSpec, side: str, label: str, value: str
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
side_label = "from" if side == "min" else "to"
|
||||
return Input(
|
||||
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."""
|
||||
parts = date_parts()
|
||||
initial_values = _iso_part_values(iso_value, parts)
|
||||
children: list[SafeText] = []
|
||||
children: list[Node] = []
|
||||
for index, part in enumerate(parts):
|
||||
if index > 0:
|
||||
children.append(
|
||||
@@ -158,7 +160,7 @@ def DateRangeField(
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""The visible half of the DateRangePicker: a single-input-looking
|
||||
container holding two segmented dates, a calendar toggle, and the two
|
||||
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
|
||||
@@ -195,8 +197,8 @@ def DateRangeField(
|
||||
children=["–"],
|
||||
),
|
||||
_segment_group(side="max", label=label, iso_value=max_value),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-date-range-calendar-toggle", ""),
|
||||
@@ -207,15 +209,15 @@ def DateRangeField(
|
||||
"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:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(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:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _footer_button(action: str, label: str, button_class: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(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
|
||||
(filled client-side into ``[data-date-range-grid]``), and the
|
||||
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
|
||||
preset_buttons = [
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-date-range-preset", preset_value),
|
||||
@@ -328,7 +330,7 @@ def DateRangePicker(
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A date-range widget: segmented manual entry plus a calendar popup.
|
||||
|
||||
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
|
||||
@@ -352,4 +354,4 @@ def DateRangePicker(
|
||||
),
|
||||
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
||||
],
|
||||
)
|
||||
).with_media(_DATE_RANGE_MEDIA)
|
||||
|
||||
+19
-21
@@ -4,9 +4,8 @@ from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
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 (
|
||||
A,
|
||||
Div,
|
||||
@@ -21,13 +20,12 @@ from games.models import Game, Purchase, Session
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||
from django.urls import reverse
|
||||
|
||||
children = children or []
|
||||
display = children if children else [name]
|
||||
display = as_children(children) or [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
@@ -38,7 +36,7 @@ def GameLink(
|
||||
attributes=[
|
||||
("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(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
children: Children = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
@@ -76,13 +74,13 @@ def GameStatus(
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||
children=[dot] + as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
@@ -90,11 +88,11 @@ def PriceConverted(
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("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_content = ""
|
||||
popover_content = ""
|
||||
@@ -131,7 +129,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_content=Safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
@@ -145,7 +143,7 @@ def NameWithIcon(
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
@@ -203,7 +201,7 @@ def _resolve_name_with_icon(
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
def PurchasePrice(purchase) -> Node:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_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."""
|
||||
options_html = "\n".join(
|
||||
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
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
return Safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
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."""
|
||||
device_id = session.device_id or "null"
|
||||
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
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
return Safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
originalDeviceId: {device_id},
|
||||
|
||||
+191
-123
@@ -3,9 +3,8 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
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.primitives import Checkbox, Div, Input, Label, Radio, Span
|
||||
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"
|
||||
|
||||
|
||||
# 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:
|
||||
if not filter_json:
|
||||
return {}
|
||||
@@ -169,7 +175,7 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
||||
|
||||
def _enum_filter(
|
||||
field_name: str, options, choice: FilterChoice, *, nullable
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
||||
|
||||
Enum fields are single-valued, so no M2M modifiers (all/only are
|
||||
@@ -200,7 +206,7 @@ def _model_filter(
|
||||
search_url,
|
||||
nullable,
|
||||
m2m_modifiers: list[LabeledOption] | None = None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A FilterSelect backed by a search endpoint.
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
def _filter_field(label: str, widget, for_widget: str = None) -> SafeText:
|
||||
"""A labelled filter field: <div><label>…</label>{widget}</div>.
|
||||
TODO: Use widget.attributes.get("id", "") to get the widget's ID
|
||||
instead of the superfluous "for" argument. This requires refactoring
|
||||
the Component function to be a class intead.
|
||||
Also see RangeSlider's TODO
|
||||
def _widget_id(widget) -> str:
|
||||
"""Best-effort id of a widget node, for the field label's ``for`` target.
|
||||
|
||||
Widgets are nodes carrying ``.attributes``, so the id is now reachable
|
||||
directly (the old free ``Component`` string couldn't expose it).
|
||||
"""
|
||||
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(
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Label(
|
||||
attributes=[
|
||||
("class", _FILTER_LABEL_CLASS),
|
||||
("for", for_widget),
|
||||
],
|
||||
children=[label],
|
||||
),
|
||||
Label(attributes=label_attributes, children=[label]),
|
||||
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."""
|
||||
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."""
|
||||
return Div(
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
@@ -314,7 +329,7 @@ def RangeSlider(
|
||||
step: str = "1",
|
||||
min_placeholder: str = "",
|
||||
max_placeholder: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""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
|
||||
@@ -334,14 +349,9 @@ def RangeSlider(
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||
children=[
|
||||
# TODO: This should be done outside the RangeSlider component, but the current Component function doesn't allow getting the id
|
||||
# Label(
|
||||
# attributes=[
|
||||
# ("class", _FILTER_LABEL_CLASS),
|
||||
# ("for", min_input_id),
|
||||
# ],
|
||||
# children=[label],
|
||||
# ),
|
||||
# The field label is rendered by the _filter_field wrapper.
|
||||
# This composite widget has no single labelable root, so the
|
||||
# label carries no `for` (the two inputs are named below).
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
@@ -376,8 +386,8 @@ def RangeSlider(
|
||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
@@ -403,7 +413,7 @@ def RangeSlider(
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=[mark_safe(_RANGE_ICON_SVG)],
|
||||
children=[Safe(_RANGE_ICON_SVG)],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
@@ -413,7 +423,7 @@ def RangeSlider(
|
||||
+ ("" 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 = (
|
||||
@@ -499,7 +509,7 @@ def DateRangeFilter(
|
||||
max_value: str = "",
|
||||
min_placeholder: str = "From",
|
||||
max_placeholder: str = "To",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A pair of ``<input type="date">`` elements representing a date range.
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def _filter_collapse_button() -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _filter_collapse_button() -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
# Slider handles are positioned in percentages, so initializing
|
||||
# them while the body is hidden is safe — no re-init on reveal.
|
||||
(
|
||||
"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",
|
||||
@@ -570,7 +582,7 @@ def _filter_collapse_button() -> SafeText:
|
||||
),
|
||||
],
|
||||
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>'
|
||||
),
|
||||
"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(
|
||||
attributes=[("class", "flex gap-3 items-center")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
@@ -595,8 +607,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||
],
|
||||
children=["Apply"],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
@@ -632,8 +644,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||
),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("id", "save-preset-btn"),
|
||||
@@ -649,8 +661,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||
],
|
||||
children=["Save Preset"],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("id", "confirm-save-preset-btn"),
|
||||
@@ -686,64 +698,104 @@ 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:
|
||||
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body
|
||||
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
|
||||
the hidden filter-json input and the Apply/Clear/preset action row."""
|
||||
return Div(
|
||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||
children=[
|
||||
_filter_collapse_button(),
|
||||
Div(
|
||||
attributes=[
|
||||
("id", "filter-bar-body"),
|
||||
(
|
||||
"class",
|
||||
"hidden border border-default-medium rounded-base p-4 "
|
||||
"bg-neutral-secondary-medium/50",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="form",
|
||||
attributes=[
|
||||
("id", _FILTER_FORM_ID),
|
||||
("onsubmit", "return applyFilterBar(event)"),
|
||||
],
|
||||
children=[
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "hidden"),
|
||||
("id", _FILTER_INPUT_ID),
|
||||
("name", "filter"),
|
||||
# NB: Component escapes attribute values, so the
|
||||
# raw JSON is passed through (no double-escape).
|
||||
("value", filter_json),
|
||||
],
|
||||
),
|
||||
*fields,
|
||||
_filter_action_row(preset_list_url, preset_save_url),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
class _FilterBarBase(BaseComponent):
|
||||
"""Shared collapsible filter-bar chrome.
|
||||
|
||||
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(
|
||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||
children=[
|
||||
_filter_collapse_button(),
|
||||
Div(
|
||||
attributes=[
|
||||
("id", "filter-bar-body"),
|
||||
(
|
||||
"class",
|
||||
"hidden border border-default-medium rounded-base p-4 "
|
||||
"bg-neutral-secondary-medium/50",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("id", _FILTER_FORM_ID),
|
||||
("onsubmit", "return applyFilterBar(event)"),
|
||||
],
|
||||
children=[
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "hidden"),
|
||||
("id", _FILTER_INPUT_ID),
|
||||
("name", "filter"),
|
||||
# NB: attribute values are escaped, so the
|
||||
# raw JSON passes through (no double-escape).
|
||||
("value", self.filter_json),
|
||||
],
|
||||
),
|
||||
*self.build_fields(),
|
||||
_filter_action_row(
|
||||
self.preset_list_url, self.preset_save_url
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def FilterBar(
|
||||
filter_json: str = "",
|
||||
status_options: list[LabeledOption] | None = None,
|
||||
preset_list_url: str = "",
|
||||
preset_save_url: str = "",
|
||||
) -> SafeText:
|
||||
class FilterBar(_FilterBarBase):
|
||||
"""Collapsible filter bar for the Game list."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filter_json: str = "",
|
||||
status_options: list[LabeledOption] | None = None,
|
||||
preset_list_url: str = "",
|
||||
preset_save_url: str = "",
|
||||
) -> None:
|
||||
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
|
||||
|
||||
if status_options is None:
|
||||
status_options = [(s.value, s.label) for s in Game.Status]
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
status_choice = _filter_get_choice(existing, "status")
|
||||
platform_choice = _filter_get_choice(existing, "platform")
|
||||
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:
|
||||
@@ -1065,13 +1117,16 @@ def _find_label(options: list[LabeledOption], value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def SessionFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
class SessionFilterBar(_FilterBarBase):
|
||||
"""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
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
device_choice = _filter_get_choice(existing, "device")
|
||||
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(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
class PurchaseFilterBar(_FilterBarBase):
|
||||
"""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
|
||||
|
||||
type_options = Purchase.TYPES
|
||||
ownership_options = Purchase.OWNERSHIP_TYPES
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "games")
|
||||
platform_choice = _filter_get_choice(existing, "platform")
|
||||
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."""
|
||||
|
||||
def build_fields(self) -> list:
|
||||
return _device_fields(self.existing)
|
||||
|
||||
|
||||
def _device_fields(existing: dict) -> list:
|
||||
from games.models import Device
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
type_options = Device.DEVICE_TYPES
|
||||
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(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
class PlatformFilterBar(_FilterBarBase):
|
||||
"""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_modifier = existing.get("name", {}).get("modifier", "EQUALS")
|
||||
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(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
class PlayEventFilterBar(_FilterBarBase):
|
||||
"""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")
|
||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||
|
||||
@@ -1456,7 +1524,7 @@ def PlayEventFilterBar(
|
||||
max_placeholder="e.g. 30",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
return fields
|
||||
|
||||
|
||||
def StringFilter(
|
||||
@@ -1464,7 +1532,7 @@ def StringFilter(
|
||||
value: str = "",
|
||||
modifier: str = "EQUALS",
|
||||
placeholder: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Renders a string filter with 8 modifier radio options and a text input."""
|
||||
from common.criteria import Modifier
|
||||
|
||||
|
||||
+168
-207
@@ -1,4 +1,11 @@
|
||||
"""Generic HTML primitives (no domain knowledge)."""
|
||||
"""Generic HTML primitives (no domain knowledge).
|
||||
|
||||
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
|
||||
per tag: they are generated from a whitelist via :func:`_html_element`, each a
|
||||
thin builder over the single :class:`Element` node class. Only elements that add
|
||||
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
|
||||
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
|
||||
"""
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
@@ -6,7 +13,20 @@ from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||
from common.components.core import (
|
||||
Attributes,
|
||||
Child,
|
||||
Children,
|
||||
Element,
|
||||
Fragment,
|
||||
HTMLAttribute,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
as_attributes,
|
||||
as_children,
|
||||
randomid,
|
||||
)
|
||||
from common.icons import get_icon
|
||||
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(
|
||||
id: str,
|
||||
popover_content: str,
|
||||
popover_content: Child,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
slot: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate popover HTML using Component(tag_name=...).
|
||||
|
||||
Single source of truth for popover HTML structure.
|
||||
Used by Popover() and the python_popover template tag bridge.
|
||||
"""
|
||||
slot: "Node | str" = "",
|
||||
) -> Node:
|
||||
"""Generate popover HTML. Single source of truth for popover structure."""
|
||||
display_content = wrapped_content if wrapped_content else slot
|
||||
|
||||
span = Span(
|
||||
@@ -69,7 +117,7 @@ def _popover_html(
|
||||
children=[popover_content],
|
||||
),
|
||||
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 "
|
||||
"from Python component -->"
|
||||
),
|
||||
@@ -79,24 +127,24 @@ def _popover_html(
|
||||
],
|
||||
)
|
||||
|
||||
return mark_safe(span + "\n" + div)
|
||||
return Fragment(span, div, separator="\n")
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
popover_content: Child,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: Children = None,
|
||||
attributes: Attributes | None = None,
|
||||
id: str = "",
|
||||
) -> str:
|
||||
children = children or []
|
||||
) -> Node:
|
||||
children = as_children(children)
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
if not id:
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
|
||||
slot = mark_safe("\n".join(children))
|
||||
slot = Fragment(*children, separator="\n") if children else ""
|
||||
return _popover_html(
|
||||
id=id,
|
||||
popover_content=popover_content,
|
||||
@@ -108,12 +156,12 @@ def Popover(
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_content: Child = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
) -> "Node | str":
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
@@ -139,11 +187,11 @@ def PopoverTruncated(
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
@@ -151,7 +199,7 @@ def A(
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
attributes = as_attributes(attributes)
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
@@ -161,14 +209,14 @@ def A(
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
return Element(
|
||||
"a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
@@ -179,8 +227,8 @@ def Button(
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
) -> Element:
|
||||
attributes = as_attributes(attributes)
|
||||
children = children or []
|
||||
|
||||
# Separate custom class from other generic attributes
|
||||
@@ -224,8 +272,8 @@ def Button(
|
||||
button_attrs.append(("name", name))
|
||||
button_attrs.extend(other_attrs)
|
||||
|
||||
return Component(
|
||||
tag_name="button",
|
||||
return Element(
|
||||
"button",
|
||||
attributes=button_attrs,
|
||||
children=children,
|
||||
)
|
||||
@@ -267,7 +315,7 @@ def _button_group_button(
|
||||
title: str = "",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||
|
||||
@@ -284,8 +332,8 @@ def _button_group_button(
|
||||
)
|
||||
)
|
||||
|
||||
button = Component(
|
||||
tag_name="button",
|
||||
button = Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("title", title),
|
||||
@@ -294,10 +342,10 @@ def _button_group_button(
|
||||
children=[slot],
|
||||
)
|
||||
|
||||
return Component(tag_name="a", attributes=a_attrs, children=[button])
|
||||
return Element("a", attributes=a_attrs, children=[button])
|
||||
|
||||
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
||||
"""Generate a button group div.
|
||||
|
||||
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
||||
@@ -305,7 +353,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
for conditional buttons (e.g., end-session only when session is active).
|
||||
"""
|
||||
buttons = buttons or []
|
||||
children: list[SafeText] = []
|
||||
children: list[Node] = []
|
||||
for btn in buttons:
|
||||
if not btn or not btn.get("slot"):
|
||||
continue
|
||||
@@ -326,79 +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(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
) -> Element:
|
||||
attributes = as_attributes(attributes)
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def Span(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="span", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Label(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="label", attributes=attributes, children=children)
|
||||
return Element("input", attributes=attributes + [("type", type)], children=children)
|
||||
|
||||
|
||||
def Checkbox(
|
||||
@@ -406,10 +389,10 @@ def Checkbox(
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
attributes = as_attributes(attributes)
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
@@ -438,10 +421,10 @@ def Radio(
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
attributes = as_attributes(attributes)
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("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
|
||||
# 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
|
||||
@@ -493,8 +466,8 @@ def Pill(
|
||||
removable: bool = False,
|
||||
extra_class: str = "",
|
||||
label_slot: bool = False,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
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
|
||||
markup single-sourced — see ``search_select.py``).
|
||||
"""
|
||||
attributes = attributes or []
|
||||
attributes = as_attributes(attributes)
|
||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||
if value != "":
|
||||
pill_attrs.append(("data-value", str(value)))
|
||||
pill_attrs.extend(attributes)
|
||||
|
||||
label_child: HTMLTag = (
|
||||
label_child: "Node | str" = (
|
||||
Span(attributes=[("data-search-select-label", "")], children=[label])
|
||||
if label_slot
|
||||
else label
|
||||
)
|
||||
children: list[HTMLTag] = [label_child]
|
||||
children: list["Node | str"] = [label_child]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
@@ -535,9 +508,12 @@ def Pill(
|
||||
return Span(attributes=pill_attrs, children=children)
|
||||
|
||||
|
||||
def CsrfInput(request) -> SafeText:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||
return mark_safe(
|
||||
def CsrfInput(request) -> Node:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.
|
||||
|
||||
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)}">'
|
||||
)
|
||||
|
||||
@@ -554,11 +530,22 @@ def ExternalScript(url: str) -> SafeText:
|
||||
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(
|
||||
year: int | None = None,
|
||||
available_years: tuple[int, ...] = (),
|
||||
url_template: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A Flowbite-datepicker year picker.
|
||||
|
||||
`year` is the selected year, or ``None`` for the all-time view (the empty
|
||||
@@ -567,8 +554,8 @@ def YearPicker(
|
||||
placeholder, substituted with the chosen year in JS (keeps this component
|
||||
decoupled from the project's URL names).
|
||||
|
||||
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||
via ``render_page(scripts=...)``.
|
||||
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
|
||||
node, so ``Page()`` loads it automatically.
|
||||
"""
|
||||
label = str(year) if year is not None else "Choose a year"
|
||||
selected = str(year) if year is not None else ""
|
||||
@@ -579,7 +566,8 @@ def YearPicker(
|
||||
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
||||
)
|
||||
years_csv = ",".join(str(y) for y in available_years)
|
||||
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
return Safe(
|
||||
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
@keydown.escape.window="pickerOpen = false">
|
||||
<button type="button"
|
||||
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
||||
@@ -632,17 +620,19 @@ document.addEventListener('DOMContentLoaded', () => {{
|
||||
picker.update();
|
||||
}}
|
||||
}});
|
||||
</script>""")
|
||||
</script>""",
|
||||
media=_YEAR_PICKER_MEDIA,
|
||||
)
|
||||
|
||||
|
||||
def AddForm(
|
||||
form,
|
||||
*,
|
||||
request,
|
||||
fields: SafeText | str | None = None,
|
||||
additional_row: SafeText | str = "",
|
||||
fields: Node | SafeText | str | None = None,
|
||||
additional_row: Node | SafeText | str = "",
|
||||
submit_class: str = "mt-3",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
||||
|
||||
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
||||
@@ -651,11 +641,11 @@ def AddForm(
|
||||
is applied to the main Submit button (the session form passes "" to match
|
||||
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 []
|
||||
|
||||
inner_form = Component(
|
||||
tag_name="form",
|
||||
inner_form = Element(
|
||||
"form",
|
||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
@@ -683,10 +673,10 @@ def SearchField(
|
||||
search_string: str = "",
|
||||
id: str = "search_string",
|
||||
placeholder: str = "Search",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Generate a search form with icon, input field, and submit button."""
|
||||
return Component(
|
||||
tag_name="form",
|
||||
return Element(
|
||||
"form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Label(
|
||||
@@ -699,7 +689,7 @@ def SearchField(
|
||||
Div(
|
||||
attributes=[("class", "relative")],
|
||||
children=[
|
||||
mark_safe(
|
||||
Safe(
|
||||
'<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" '
|
||||
'fill="none" viewBox="0 0 24 24">'
|
||||
@@ -724,8 +714,8 @@ def SearchField(
|
||||
("required", ""),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
@@ -746,13 +736,13 @@ def SearchField(
|
||||
|
||||
|
||||
def H1(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
children: Children = None,
|
||||
badge: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Heading with optional badge count."""
|
||||
children = children or []
|
||||
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
||||
badge_html = ""
|
||||
badge_html: Node | str = ""
|
||||
|
||||
if badge:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
@@ -767,21 +757,20 @@ def H1(
|
||||
children=[badge],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
return Element(
|
||||
"h1",
|
||||
attributes=[("class", heading_class)],
|
||||
children=(children if isinstance(children, list) else [children])
|
||||
+ ([badge_html] if badge_html else []),
|
||||
children=as_children(children) + ([badge_html] if badge_html else []),
|
||||
)
|
||||
|
||||
|
||||
def Modal(
|
||||
modal_id: str,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||
children = children or []
|
||||
outer = Div(
|
||||
return Div(
|
||||
attributes=[
|
||||
("id", modal_id),
|
||||
(
|
||||
@@ -799,52 +788,24 @@ def Modal(
|
||||
"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(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Element:
|
||||
"""Styled table cell."""
|
||||
children = children or []
|
||||
return Td(
|
||||
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.
|
||||
|
||||
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"):
|
||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||
|
||||
cell_elements: list[SafeText] = []
|
||||
cell_elements: list[Node] = []
|
||||
for i, cell in enumerate(cells):
|
||||
if i == 0:
|
||||
cell_elements.append(
|
||||
@@ -903,18 +864,18 @@ def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
return mark_safe(get_icon(name))
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
return Safe(get_icon(name))
|
||||
|
||||
|
||||
def TableHeader(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Element:
|
||||
"""Table caption."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="caption",
|
||||
return Element(
|
||||
"caption",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -922,7 +883,7 @@ def TableHeader(
|
||||
"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(
|
||||
columns: list[str] | None = None,
|
||||
rows: list | None = None,
|
||||
header_action: SafeText | str | None = None,
|
||||
header_action: Child | None = None,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
@@ -1024,7 +985,7 @@ def SimpleTable(
|
||||
if page_obj and elided_page_range:
|
||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||
|
||||
return mark_safe(
|
||||
return Safe(
|
||||
'<div class="shadow-md" hx-boost="false">'
|
||||
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
|
||||
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
|
||||
@@ -1044,7 +1005,7 @@ def paginated_table_content(
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||
|
||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||
|
||||
@@ -21,11 +21,13 @@ user types.
|
||||
from collections.abc import Callable, Iterable
|
||||
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
|
||||
|
||||
# Both comboboxes are wired by search_select.js.
|
||||
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
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()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> SafeText:
|
||||
def _hidden_input(name: str, value) -> Node:
|
||||
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
|
||||
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."""
|
||||
@@ -159,7 +161,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
||||
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
def _option_row(option: SearchSelectOption) -> Node:
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
@@ -174,14 +176,14 @@ def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
|
||||
def _combobox_shell(
|
||||
*,
|
||||
container_attributes: list[HTMLAttribute],
|
||||
pills: SafeText,
|
||||
search_attributes: list[HTMLAttribute],
|
||||
options_children: list[SafeText],
|
||||
container_attributes: Attributes,
|
||||
pills: Node,
|
||||
search_attributes: Attributes,
|
||||
options_children: list[Node],
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[SafeText] | None = None,
|
||||
) -> SafeText:
|
||||
templates: list[Node] | None = None,
|
||||
) -> Node:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
|
||||
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: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||
children: list[Node] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
@@ -232,7 +234,7 @@ def SearchSelect(
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
autofocus: bool = False,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(option) for option in (selected 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
|
||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||
pills_children: list[SafeText] = []
|
||||
pills_children: list[Node] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
@@ -283,7 +285,7 @@ def SearchSelect(
|
||||
|
||||
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||
# multi-select adds chosen items. ──
|
||||
templates: list[SafeText] = []
|
||||
templates: list[Node] = []
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
@@ -322,12 +324,12 @@ def SearchSelect(
|
||||
always_visible=always_visible,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
).with_media(_SEARCH_SELECT_MEDIA)
|
||||
|
||||
|
||||
def _filter_remove_button() -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _filter_remove_button() -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("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"."""
|
||||
symbol = "✓" if kind == "include" else "✗"
|
||||
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)")."""
|
||||
return Span(
|
||||
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:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _filter_action_button(action: str, symbol: str, title: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("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."""
|
||||
return Div(
|
||||
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
|
||||
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||
return Div(
|
||||
@@ -432,7 +434,7 @@ def FilterSelect(
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
free_text: bool = False,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||
|
||||
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
|
||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||
pills_children: list[SafeText] = []
|
||||
pills_children: list[Node] = []
|
||||
if active_modifier_label:
|
||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||
for option in included:
|
||||
@@ -504,7 +506,7 @@ def FilterSelect(
|
||||
|
||||
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||
templates: list[SafeText] = [
|
||||
templates: list[Node] = [
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-include")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
||||
@@ -557,7 +559,7 @@ def FilterSelect(
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
).with_media(_SEARCH_SELECT_MEDIA)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
|
||||
+39
-12
@@ -8,6 +8,7 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from common.components import Node
|
||||
|
||||
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||
_THEME_FOUC_SCRIPT = """<script>
|
||||
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
|
||||
|
||||
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||
"""Top navigation bar."""
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node":
|
||||
"""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")
|
||||
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">
|
||||
<a href="{reverse("games:index")}"
|
||||
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(
|
||||
content: SafeText | str,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
) -> 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
|
||||
|
||||
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)
|
||||
year = global_current_year(request)["global_current_year"]
|
||||
navbar = Navbar(
|
||||
@@ -309,9 +333,12 @@ def Page(
|
||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||
f" {django_htmx_script(nonce=None)}\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'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
||||
# served locally so pages work offline (and in browser tests). The mask
|
||||
# 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"
|
||||
" </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' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||
" </div>\n"
|
||||
f" {scripts}\n"
|
||||
f" {all_scripts}\n"
|
||||
f" {_main_script(mastered)}\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'
|
||||
@@ -339,10 +366,10 @@ def Page(
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: SafeText | str,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> 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.
|
||||
@@ -21,9 +21,10 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<html>
|
||||
<head>
|
||||
<title>Boolean filter E2E</title>
|
||||
<script src="/static/js/range_slider.js" defer></script>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" defer></script>
|
||||
<script src="/static/js/htmx.min.js"></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/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
|
||||
@@ -29,9 +29,10 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<html>
|
||||
<head>
|
||||
<title>Date filter E2E</title>
|
||||
<script src="/static/js/range_slider.js" defer></script>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" defer></script>
|
||||
<script src="/static/js/htmx.min.js"></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/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
|
||||
@@ -28,10 +28,11 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<html>
|
||||
<head>
|
||||
<title>Date range picker E2E</title>
|
||||
<script src="/static/js/range_slider.js" defer></script>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
<script src="/static/js/htmx.min.js"></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/filter_bar.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
|
||||
@@ -17,9 +17,10 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<html>
|
||||
<head>
|
||||
<title>Range Slider E2E</title>
|
||||
<script src="/static/js/range_slider.js" defer></script>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" defer></script>
|
||||
<script src="/static/js/htmx.min.js"></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/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
|
||||
@@ -4,35 +4,43 @@ from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from common.components import SearchSelect
|
||||
|
||||
|
||||
def e2e_test_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False
|
||||
)}
|
||||
{
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-search-select/", e2e_test_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||
|
||||
@@ -16,9 +16,10 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<html>
|
||||
<head>
|
||||
<title>String filter E2E</title>
|
||||
<script src="/static/js/range_slider.js" defer></script>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" defer></script>
|
||||
<script src="/static/js/htmx.min.js"></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/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
|
||||
@@ -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()
|
||||
+34
-21
@@ -6,6 +6,7 @@ from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
render,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.primitives import Checkbox
|
||||
@@ -28,23 +29,32 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||
checked = self.check_test(value)
|
||||
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
|
||||
attributes = [
|
||||
(k, str(v))
|
||||
for k, v in final_attrs.items()
|
||||
if k not in ("type", "name", "value", "checked")
|
||||
]
|
||||
|
||||
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||
return str(Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes
|
||||
))
|
||||
# render() returns a safe string (Django widgets must not be autoescaped).
|
||||
return render(
|
||||
Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PrimitiveWidgetsMixin:
|
||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
@@ -130,19 +140,22 @@ class SearchSelectWidget(forms.Widget):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||
autofocus = bool((attrs or {}).get("autofocus"))
|
||||
return SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
# Django widgets must return a safe string; the component is a node.
|
||||
return render(
|
||||
SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
)
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
|
||||
@@ -1952,9 +1952,17 @@
|
||||
border-top-left-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 {
|
||||
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 {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
@@ -2055,6 +2063,12 @@
|
||||
.border-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-color: var(--color-brand);
|
||||
}
|
||||
@@ -2185,6 +2199,9 @@
|
||||
.bg-gray-100 {
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
.bg-gray-200 {
|
||||
background-color: var(--color-gray-200);
|
||||
}
|
||||
.bg-gray-400 {
|
||||
background-color: var(--color-gray-400);
|
||||
}
|
||||
@@ -2667,6 +2684,9 @@
|
||||
.text-blue-500 {
|
||||
color: var(--color-blue-500);
|
||||
}
|
||||
.text-blue-600 {
|
||||
color: var(--color-blue-600);
|
||||
}
|
||||
.text-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 {
|
||||
@media (hover: hover) {
|
||||
@@ -3672,6 +3699,11 @@
|
||||
border-color: var(--color-amber-700);
|
||||
}
|
||||
}
|
||||
.dark\:border-blue-500 {
|
||||
&:is(.dark *) {
|
||||
border-color: var(--color-blue-500);
|
||||
}
|
||||
}
|
||||
.dark\:border-blue-700 {
|
||||
&:is(.dark *) {
|
||||
border-color: var(--color-blue-700);
|
||||
@@ -3707,6 +3739,11 @@
|
||||
border-color: var(--color-red-700);
|
||||
}
|
||||
}
|
||||
.dark\:border-transparent {
|
||||
&:is(.dark *) {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
.dark\:bg-amber-900 {
|
||||
&:is(.dark *) {
|
||||
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 {
|
||||
&:is(.dark *) {
|
||||
&:hover {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -38,8 +38,9 @@ function setupElementHandlers() {
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
onSwap("#id_type", (typeSelect) => {
|
||||
setupElementHandlers();
|
||||
typeSelect.addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -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)});})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@
|
||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||
* No HTMX — plain fetch() and window.location for all interactions.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
@@ -410,27 +412,25 @@
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── Inject search inputs into filter forms ──
|
||||
function injectSearchInputs() {
|
||||
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
});
|
||||
// ── Inject the search input into a filter form ──
|
||||
function injectSearchInput(form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,25 +438,25 @@
|
||||
*/
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,14 +464,14 @@
|
||||
*/
|
||||
function setupStringFilters() {
|
||||
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
window.toggleStringFilterInput(this);
|
||||
});
|
||||
radio.addEventListener('change', function () {
|
||||
window.toggleStringFilterInput(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
onSwap('[id^="filter-bar-form"]', function (form) {
|
||||
injectSearchInput(form);
|
||||
setupDeselectableRadios();
|
||||
setupStringFilters();
|
||||
loadPresets();
|
||||
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
+194
-200
@@ -8,229 +8,223 @@
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes).
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll(force) {
|
||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||
if (force) slider._rsInit = false;
|
||||
if (slider._rsInit) return;
|
||||
slider._rsInit = true;
|
||||
function initializeSlider(slider) {
|
||||
var mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
// ── Helpers ──
|
||||
|
||||
// ── Helpers ──
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
function getTargetValue(target, defaultVal) {
|
||||
if (!target || target.value === "") return defaultVal;
|
||||
var parsed = parseInt(target.value, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
|
||||
function getTargetValue(target, defaultVal) {
|
||||
if (!target || target.value === "") return defaultVal;
|
||||
var parsed = parseInt(target.value, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
// ── Track fill positioning ──
|
||||
|
||||
// ── Track fill positioning ──
|
||||
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minVal);
|
||||
var rightPct = valueToPercent(maxVal);
|
||||
if (leftPct > rightPct) {
|
||||
var tmp = leftPct;
|
||||
leftPct = rightPct;
|
||||
rightPct = tmp;
|
||||
}
|
||||
var widthPct = rightPct - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minVal);
|
||||
var rightPct = valueToPercent(maxVal);
|
||||
if (leftPct > rightPct) {
|
||||
var tmp = leftPct;
|
||||
leftPct = rightPct;
|
||||
rightPct = tmp;
|
||||
}
|
||||
var widthPct = rightPct - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHandles() {
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
minHandle.style.left = valueToPercent(minVal) + "%";
|
||||
maxHandle.style.left = valueToPercent(maxVal) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
function updateHandles() {
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
minHandle.style.left = valueToPercent(minVal) + "%";
|
||||
maxHandle.style.left = valueToPercent(maxVal) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
|
||||
// ── Dragging ──
|
||||
// ── Dragging ──
|
||||
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var value = percentToValue(clamp(pct, 0, 100));
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var value = percentToValue(clamp(pct, 0, 100));
|
||||
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else {
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// ── Sync from number inputs back to handles ──
|
||||
|
||||
function syncFromInputs(e) {
|
||||
if (mode === "point") {
|
||||
var src = (e && e.target) || minTarget || maxTarget;
|
||||
var val = src ? src.value : "";
|
||||
setTargetValue(minTarget, val);
|
||||
setTargetValue(maxTarget, val);
|
||||
} else if (e && e.target) {
|
||||
var minVal = getTargetValue(minTarget, dataMin);
|
||||
var maxVal = getTargetValue(maxTarget, dataMax);
|
||||
if (e.target === minTarget) {
|
||||
if (minVal > maxVal) {
|
||||
setTargetValue(maxTarget, minVal);
|
||||
}
|
||||
} else if (e.target === maxTarget) {
|
||||
if (maxVal < minVal) {
|
||||
setTargetValue(minTarget, maxVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
function enforceStrictBounds(e) {
|
||||
if (e && e.target) {
|
||||
var val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) {
|
||||
var clamped = clamp(val, dataMin, dataMax);
|
||||
if (clamped !== val) {
|
||||
setTargetValue(e.target, clamped);
|
||||
e.target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minTarget) {
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
minTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
if (maxTarget) {
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// ── Sync from number inputs back to handles ──
|
||||
|
||||
function syncFromInputs(e) {
|
||||
if (mode === "point") {
|
||||
var src = (e && e.target) || minTarget || maxTarget;
|
||||
var val = src ? src.value : "";
|
||||
setTargetValue(minTarget, val);
|
||||
setTargetValue(maxTarget, val);
|
||||
} else if (e && e.target) {
|
||||
var minVal = getTargetValue(minTarget, dataMin);
|
||||
var maxVal = getTargetValue(maxTarget, dataMax);
|
||||
if (e.target === minTarget) {
|
||||
if (minVal > maxVal) {
|
||||
setTargetValue(maxTarget, minVal);
|
||||
}
|
||||
} else if (e.target === maxTarget) {
|
||||
if (maxVal < minVal) {
|
||||
setTargetValue(minTarget, maxVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
function enforceStrictBounds(e) {
|
||||
if (e && e.target) {
|
||||
var val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) {
|
||||
var clamped = clamp(val, dataMin, dataMax);
|
||||
if (clamped !== val) {
|
||||
setTargetValue(e.target, clamped);
|
||||
e.target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minTarget) {
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
minTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
if (maxTarget) {
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
window.initRangeSliders = initAll;
|
||||
onSwap(".range-slider", initializeSlider);
|
||||
})();
|
||||
@@ -12,8 +12,8 @@
|
||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||
* element._searchSelectInit.
|
||||
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
||||
* page load and every htmx-swapped fragment, once per widget.
|
||||
*
|
||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||
* 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
|
||||
* place (the Python components), never duplicated here.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
@@ -32,14 +34,6 @@
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
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 search = container.querySelector("[data-search-select-search]");
|
||||
const options = container.querySelector("[data-search-select-options]");
|
||||
@@ -666,6 +660,5 @@
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
onSwap("[data-search-select]", initWidget);
|
||||
})();
|
||||
|
||||
@@ -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.
|
||||
* @param {Date} date
|
||||
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
}
|
||||
|
||||
export {
|
||||
onSwap,
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
|
||||
+9
-10
@@ -3,19 +3,18 @@ registration/login.html)."""
|
||||
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.http import HttpResponse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from 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.layout import render_page
|
||||
|
||||
|
||||
def _login_content(form, request) -> SafeText:
|
||||
table = Component(
|
||||
tag_name="table",
|
||||
def _login_content(form, request) -> Node:
|
||||
table = Element(
|
||||
"table",
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
mark_safe(str(form.as_table())),
|
||||
Safe(str(form.as_table())),
|
||||
Tr(
|
||||
children=[
|
||||
Td(),
|
||||
@@ -31,13 +30,13 @@ def _login_content(form, request) -> SafeText:
|
||||
return Div(
|
||||
[("class", "flex items-center flex-col")],
|
||||
[
|
||||
Component(
|
||||
tag_name="h2",
|
||||
Element(
|
||||
"h2",
|
||||
attributes=[("class", "text-3xl text-white mb-8")],
|
||||
children=["Please log in to continue"],
|
||||
),
|
||||
Component(
|
||||
tag_name="form",
|
||||
Element(
|
||||
"form",
|
||||
attributes=[("method", "post")],
|
||||
children=[table],
|
||||
),
|
||||
|
||||
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
@@ -12,7 +12,6 @@ from common.components import (
|
||||
Icon,
|
||||
paginated_table_content,
|
||||
DeviceFilterBar,
|
||||
ModuleScript,
|
||||
)
|
||||
from common.layout import render_page
|
||||
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_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage devices",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from games.models import FilterPreset
|
||||
|
||||
@@ -40,7 +39,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
|
||||
if not items:
|
||||
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
|
||||
|
||||
+17
-19
@@ -11,14 +11,15 @@ from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
FilterBar,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
@@ -27,9 +28,11 @@ from common.components import (
|
||||
Modal,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
PurchasePrice,
|
||||
Safe,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Ul,
|
||||
@@ -145,14 +148,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
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):
|
||||
data_items.append(Li(children=["No associated data"]))
|
||||
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:delete_game", args=[game.id])),
|
||||
("hx-replace-url", "true"),
|
||||
@@ -388,7 +388,7 @@ _PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: f
|
||||
</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."""
|
||||
replacements = {
|
||||
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
||||
@@ -402,7 +402,7 @@ def _played_row(game: Game, request: HttpRequest) -> SafeText:
|
||||
html = _PLAYED_ROW_TEMPLATE
|
||||
for token, value in replacements.items():
|
||||
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:
|
||||
@@ -410,14 +410,12 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
|
||||
popover_content=tooltip,
|
||||
wrapped_classes="flex gap-2 items-center",
|
||||
id=popover_id,
|
||||
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
children=[Safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
)
|
||||
|
||||
|
||||
def _meta_row(
|
||||
label: str, value: SafeText | str, extra: SafeText | str = ""
|
||||
) -> SafeText:
|
||||
children: list[SafeText | str] = [
|
||||
def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
|
||||
children: list[Node | str] = [
|
||||
Span(attributes=[("class", "uppercase")], children=[label]),
|
||||
value,
|
||||
]
|
||||
@@ -444,8 +442,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
||||
edit_link = A(
|
||||
href=reverse("games:edit_game", args=[game.id]),
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[("type", "button"), ("class", edit_class)],
|
||||
children=["Edit"],
|
||||
)
|
||||
@@ -458,8 +456,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
||||
("hx-target", "#global-modal-container"),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[("type", "button"), ("class", delete_class)],
|
||||
children=["Delete"],
|
||||
)
|
||||
@@ -567,7 +565,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
|
||||
]
|
||||
+ (
|
||||
[
|
||||
mark_safe(" "),
|
||||
Safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
|
||||
+4
-11
@@ -13,17 +13,14 @@ from django.urls import reverse
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from common.components import ExternalScript
|
||||
from common.layout import render_page
|
||||
from common.time import format_duration
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
from games.views.stats_content import stats_content
|
||||
from games.views.stats_data import compute_stats
|
||||
|
||||
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker.
|
||||
_STATS_SCRIPTS = ExternalScript(
|
||||
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
|
||||
)
|
||||
# The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
|
||||
# component, so Page() loads it automatically on the stats pages.
|
||||
|
||||
|
||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||
@@ -77,9 +74,7 @@ def use_custom_redirect(
|
||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||
request.session["return_path"] = request.path
|
||||
data = compute_stats(None)
|
||||
return render_page(
|
||||
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
||||
)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -93,9 +88,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||
request.session["return_path"] = request.path
|
||||
data = compute_stats(year)
|
||||
return render_page(
|
||||
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
||||
)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
@@ -12,7 +12,6 @@ from common.components import (
|
||||
Icon,
|
||||
paginated_table_content,
|
||||
PlatformFilterBar,
|
||||
ModuleScript,
|
||||
)
|
||||
from common.layout import render_page
|
||||
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_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage platforms",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
@@ -151,14 +151,11 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
preset_list_url=reverse("games:list_presets") + "?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(
|
||||
request,
|
||||
content,
|
||||
title="Manage play events",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+10
-12
@@ -14,18 +14,20 @@ from django.utils.safestring import SafeText, mark_safe
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
GameLink,
|
||||
Icon,
|
||||
LinkedPurchase,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Node,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
TableRow,
|
||||
@@ -129,22 +131,18 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
from common.components import ModuleScript, PurchaseFilterBar
|
||||
from common.components import PurchaseFilterBar
|
||||
|
||||
filter_bar = PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
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")
|
||||
|
||||
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||
@@ -342,8 +340,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
return Modal(
|
||||
"refund-confirmation-modal",
|
||||
children=[
|
||||
Component(
|
||||
tag_name="h1",
|
||||
Element(
|
||||
"h1",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
|
||||
+10
-10
@@ -11,6 +11,7 @@ from django.utils import timezone
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
@@ -19,7 +20,9 @@ from common.components import (
|
||||
Icon,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
Safe,
|
||||
SearchField,
|
||||
SessionDeviceSelector,
|
||||
paginated_table_content,
|
||||
@@ -176,14 +179,11 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
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", ""))
|
||||
|
||||
|
||||
def _session_fields(form) -> SafeText:
|
||||
def _session_fields(form) -> Fragment:
|
||||
"""Manual per-field layout for the session form.
|
||||
|
||||
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.
|
||||
"""
|
||||
rows: list[SafeText] = []
|
||||
rows: list[Node] = []
|
||||
for field in form:
|
||||
children: list[SafeText | str] = [
|
||||
mark_safe(str(field.label_tag())),
|
||||
mark_safe(str(field)),
|
||||
children: list[Node | str] = [
|
||||
Safe(str(field.label_tag())),
|
||||
Safe(str(field)),
|
||||
]
|
||||
if field.name in ("timestamp_start", "timestamp_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))
|
||||
return mark_safe("\n".join(rows))
|
||||
return Fragment(*rows, separator="\n")
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -9,9 +9,18 @@ from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import A, Component, Div, GameLink, YearPicker
|
||||
from common.components import (
|
||||
A,
|
||||
Div,
|
||||
Element,
|
||||
GameLink,
|
||||
Node,
|
||||
Safe,
|
||||
Td,
|
||||
Th,
|
||||
Tr,
|
||||
YearPicker,
|
||||
)
|
||||
from common.time import durationformat, format_duration
|
||||
|
||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||
@@ -19,41 +28,40 @@ _CELL_MONO = f"{_CELL} font-mono"
|
||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||
|
||||
|
||||
def _td(children, cls: str = _CELL_MONO) -> SafeText:
|
||||
def _td(children, cls: str = _CELL_MONO) -> Node:
|
||||
if not isinstance(children, list):
|
||||
children = [children]
|
||||
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
|
||||
return Component(tag_name="td", attributes=[("class", cls)], children=children)
|
||||
return Td(attributes=[("class", cls)], children=children)
|
||||
|
||||
|
||||
def _th(text: str, cls: str = _CELL) -> SafeText:
|
||||
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
|
||||
def _th(text: str, cls: str = _CELL) -> Node:
|
||||
return Th(attributes=[("class", cls)], children=[text])
|
||||
|
||||
|
||||
def _tr(cells: list) -> SafeText:
|
||||
return Component(tag_name="tr", children=cells)
|
||||
def _tr(cells: list) -> Node:
|
||||
return Tr(children=cells)
|
||||
|
||||
|
||||
def _kv(label, value) -> SafeText:
|
||||
def _kv(label, value) -> Node:
|
||||
"""A label/value row: plain label cell + mono value cell."""
|
||||
return _tr([_td(label, _CELL), _td(value)])
|
||||
|
||||
|
||||
def _h1(title: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
def _h1(title: str) -> Node:
|
||||
return Element(
|
||||
"h1",
|
||||
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
||||
children=[title],
|
||||
)
|
||||
|
||||
|
||||
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
|
||||
def _table(rows: list, thead: Node | None = None) -> Node:
|
||||
children = []
|
||||
if thead is not None:
|
||||
children.append(thead)
|
||||
children.append(Component(tag_name="tbody", children=rows))
|
||||
return Component(
|
||||
tag_name="table",
|
||||
children.append(Element("tbody", children=rows))
|
||||
return Element(
|
||||
"table",
|
||||
attributes=[("class", "responsive-table")],
|
||||
children=children,
|
||||
)
|
||||
@@ -63,7 +71,7 @@ def _dur(value) -> str:
|
||||
return format_duration(value, durationformat)
|
||||
|
||||
|
||||
def _purchase_name(purchase) -> SafeText:
|
||||
def _purchase_name(purchase) -> Node:
|
||||
"""Mirror of the `purchase-name` partial in the old template."""
|
||||
game_name = getattr(purchase, "game_name", None)
|
||||
first_game = purchase.first_game
|
||||
@@ -71,12 +79,12 @@ def _purchase_name(purchase) -> SafeText:
|
||||
name = game_name or purchase.name
|
||||
link = GameLink(first_game.id, name)
|
||||
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
|
||||
return GameLink(first_game.id, name)
|
||||
|
||||
|
||||
def _year_nav(year, year_range, url_template) -> SafeText:
|
||||
def _year_nav(year, year_range, url_template) -> Node:
|
||||
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
|
||||
# for the all-time view. Normalize to int-or-None so nothing downstream has
|
||||
# to know about the "Alltime" sentinel.
|
||||
@@ -107,7 +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")
|
||||
rows = [
|
||||
_kv("Hours", ctx.get("total_hours")),
|
||||
@@ -186,7 +194,7 @@ def _playtime_table(ctx) -> SafeText:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _purchases_table(ctx) -> SafeText:
|
||||
def _purchases_table(ctx) -> Node:
|
||||
rows = [
|
||||
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||
_kv(
|
||||
@@ -213,18 +221,18 @@ def _purchases_table(ctx) -> SafeText:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th(header), _th("Playtime")])],
|
||||
)
|
||||
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _finished_table(purchases) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _finished_table(purchases) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||
)
|
||||
rows = [
|
||||
@@ -234,9 +242,9 @@ def _finished_table(purchases) -> SafeText:
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _priced_table(purchases, currency) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _priced_table(purchases, currency) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[
|
||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||
],
|
||||
@@ -254,7 +262,7 @@ def _priced_table(purchases, currency) -> SafeText:
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def stats_content(ctx: dict) -> SafeText:
|
||||
def stats_content(ctx: dict) -> Node:
|
||||
year = ctx.get("year")
|
||||
currency = ctx.get("total_spent_currency")
|
||||
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
||||
|
||||
@@ -8,9 +8,9 @@ from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import P
|
||||
@@ -89,8 +89,8 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
||||
),
|
||||
],
|
||||
)
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[("method", "post"), ("class", "dark:text-white")],
|
||||
children=[CsrfInput(request), inner],
|
||||
)
|
||||
|
||||
@@ -11,6 +11,12 @@ pkgs.mkShell {
|
||||
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 = ''
|
||||
uv venv --clear
|
||||
. .venv/bin/activate
|
||||
|
||||
+213
-144
@@ -2,21 +2,29 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import django
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common import components
|
||||
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):
|
||||
"""Test Component() works correctly with caching transparent."""
|
||||
"""Test Element() renders correctly with caching transparent."""
|
||||
|
||||
def test_tag_name_component(self):
|
||||
result = components.Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "test")],
|
||||
children="hello",
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div",
|
||||
attributes=[("class", "test")],
|
||||
children="hello",
|
||||
)
|
||||
)
|
||||
self.assertEqual(result, '<div class="test">hello</div>')
|
||||
|
||||
@@ -28,9 +36,17 @@ class ComponentCacheTest(unittest.TestCase):
|
||||
components._render_element.cache_clear()
|
||||
|
||||
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
|
||||
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()
|
||||
self.assertEqual(info.misses, misses) # no new miss
|
||||
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)
|
||||
|
||||
def test_safe_and_unsafe_children_do_not_collide(self):
|
||||
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
|
||||
render differently — the cache key must keep them distinct."""
|
||||
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
|
||||
unsafe = components.Component(tag_name="span", children=["<b>x</b>"])
|
||||
"""A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
|
||||
the cache key must keep them distinct."""
|
||||
safe = str(
|
||||
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>", unsafe)
|
||||
self.assertNotEqual(safe, unsafe)
|
||||
@@ -114,33 +132,37 @@ class PopoverDeterministicTest(unittest.TestCase):
|
||||
"""Test that Popover() produces deterministic HTML output."""
|
||||
|
||||
def test_same_popover_same_id(self):
|
||||
r1 = components.Popover("hello", wrapped_content="hello")
|
||||
r2 = components.Popover("hello", wrapped_content="hello")
|
||||
r1 = str(components.Popover("hello", wrapped_content="hello"))
|
||||
r2 = str(components.Popover("hello", wrapped_content="hello"))
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test_different_content_different_id(self):
|
||||
r1 = components.Popover("content_a", wrapped_content="content_a")
|
||||
r2 = components.Popover("content_b", wrapped_content="content_b")
|
||||
r1 = str(components.Popover("content_a", wrapped_content="content_a"))
|
||||
r2 = str(components.Popover("content_b", wrapped_content="content_b"))
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_wrapped_classes_affect_id(self):
|
||||
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
|
||||
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
|
||||
r1 = str(
|
||||
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)
|
||||
|
||||
def test_wrapped_content_affects_id(self):
|
||||
r1 = components.Popover("popover", wrapped_content="wrapped_a")
|
||||
r2 = components.Popover("popover", wrapped_content="wrapped_b")
|
||||
r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
|
||||
r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_popover_content_affects_id(self):
|
||||
r1 = components.Popover("popover_a", wrapped_content="wrapped")
|
||||
r2 = components.Popover("popover_b", wrapped_content="wrapped")
|
||||
r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
|
||||
r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_full_html_deterministic(self):
|
||||
r1 = components.Popover("hello world", wrapped_content="hello world")
|
||||
r2 = components.Popover("hello world", wrapped_content="hello world")
|
||||
r1 = str(components.Popover("hello world", wrapped_content="hello world"))
|
||||
r2 = str(components.Popover("hello world", wrapped_content="hello world"))
|
||||
self.assertEqual(r1.encode(), r2.encode())
|
||||
|
||||
|
||||
@@ -180,26 +202,26 @@ class ComponentReturnTypeTest(unittest.TestCase):
|
||||
"""Test that component functions return SafeText and render correctly."""
|
||||
|
||||
def test_div_returns_safe_text(self):
|
||||
result = components.Div([("class", "x")], "hello")
|
||||
result = str(components.Div([("class", "x")], "hello"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
def test_div_deterministic(self):
|
||||
r1 = components.Div([("class", "x")], "hello")
|
||||
r2 = components.Div([("class", "x")], "hello")
|
||||
r1 = str(components.Div([("class", "x")], "hello"))
|
||||
r2 = str(components.Div([("class", "x")], "hello"))
|
||||
self.assertEqual(r1, r2)
|
||||
self.assertIn('<div class="x">hello</div>', r1)
|
||||
|
||||
def test_div_no_args(self):
|
||||
result = components.Div(children="test")
|
||||
result = str(components.Div(children="test"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<div>test</div>", result)
|
||||
|
||||
def test_a_returns_safe_text(self):
|
||||
result = components.A([], "link")
|
||||
result = str(components.A([], "link"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
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)
|
||||
|
||||
def test_a_url_name_reversed(self):
|
||||
@@ -208,35 +230,35 @@ class ComponentReturnTypeTest(unittest.TestCase):
|
||||
with patch(
|
||||
"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)
|
||||
|
||||
def test_a_no_url_or_href(self):
|
||||
result = components.A([], "link")
|
||||
result = str(components.A([], "link"))
|
||||
self.assertIn("<a>link</a>", result)
|
||||
self.assertNotIn("href=", result)
|
||||
|
||||
def test_a_both_url_name_and_href_raises(self):
|
||||
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):
|
||||
result = components.Button([], "click")
|
||||
result = str(components.Button([], "click"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<button", result)
|
||||
|
||||
def test_button_default_colors(self):
|
||||
result = components.Button([], "click")
|
||||
result = str(components.Button([], "click"))
|
||||
self.assertIn("text-white bg-brand", result)
|
||||
|
||||
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.assertIn("Game", result)
|
||||
self.assertNotIn("<a ", result)
|
||||
|
||||
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.assertNotIsInstance(result, tuple)
|
||||
|
||||
@@ -246,21 +268,23 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
|
||||
def test_component_output_starts_with_tag(self):
|
||||
for label, html in [
|
||||
("A", components.A(href="/foo", children=["link"])),
|
||||
("Button", components.Button([], "click")),
|
||||
("Div", components.Div([], ["hello"])),
|
||||
("Input", components.Input()),
|
||||
("ButtonGroup", components.ButtonGroup([])),
|
||||
("A", str(components.A(href="/foo", children=["link"]))),
|
||||
("Button", str(components.Button([], "click"))),
|
||||
("Div", str(components.Div([], ["hello"]))),
|
||||
("Input", str(components.Input())),
|
||||
("ButtonGroup", str(components.ButtonGroup([]))),
|
||||
(
|
||||
"ButtonGroup with buttons",
|
||||
components.ButtonGroup(
|
||||
[{"href": "/", "slot": components.Icon("edit")}]
|
||||
str(
|
||||
components.ButtonGroup(
|
||||
[{"href": "/", "slot": components.Icon("edit")}]
|
||||
)
|
||||
),
|
||||
),
|
||||
("SearchField", components.SearchField()),
|
||||
("PriceConverted", components.PriceConverted(["27 CZK"])),
|
||||
("H1", components.H1(["Title"])),
|
||||
("H1 with badge", components.H1(["Title"], badge="3")),
|
||||
("SearchField", str(components.SearchField())),
|
||||
("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
|
||||
("H1", str(components.H1(["Title"]))),
|
||||
("H1 with badge", str(components.H1(["Title"], badge="3"))),
|
||||
]:
|
||||
with self.subTest(component=label):
|
||||
self.assertTrue(
|
||||
@@ -269,90 +293,112 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_button_with_icon_children_not_escaped(self):
|
||||
result = components.Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "LOG"],
|
||||
result = str(
|
||||
components.Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "LOG"],
|
||||
)
|
||||
)
|
||||
self.assertTrue(str(result).startswith("<button"))
|
||||
|
||||
def test_popover_with_button_children_not_escaped(self):
|
||||
result = components.Popover(
|
||||
popover_content="test tooltip",
|
||||
children=[
|
||||
components.Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "test"],
|
||||
),
|
||||
],
|
||||
result = str(
|
||||
components.Popover(
|
||||
popover_content="test tooltip",
|
||||
children=[
|
||||
components.Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "test"],
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertTrue(str(result).startswith("<span data-popover-target"))
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
class ComponentEdgeCasesTest(unittest.TestCase):
|
||||
"""Test Component() edge cases and error handling."""
|
||||
"""Test Element() edge cases and error handling."""
|
||||
|
||||
def test_no_tag_name_raises(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
components.Component(children="hello")
|
||||
str(components.Element("", children="hello"))
|
||||
self.assertIn("tag_name", str(ctx.exception))
|
||||
|
||||
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)
|
||||
|
||||
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("<div>", result)
|
||||
self.assertIn("</div>", result)
|
||||
|
||||
def test_raw_html_children_are_escaped(self):
|
||||
result = components.Component(
|
||||
tag_name="div", children=["<script>alert('xss')</script>"]
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=["<script>alert('xss')</script>"]
|
||||
)
|
||||
)
|
||||
self.assertNotIn("<script>", result)
|
||||
self.assertIn("<script>", result)
|
||||
|
||||
def test_mark_safe_children_pass_through(self):
|
||||
result = components.Component(
|
||||
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
||||
def test_safe_node_children_pass_through(self):
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=[components.Safe("<span>safe</span>")]
|
||||
)
|
||||
)
|
||||
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("<span>safe</span>", result)
|
||||
|
||||
def test_attribute_values_are_escaped(self):
|
||||
result = components.Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-x", 'foo"bar')],
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div",
|
||||
attributes=[("data-x", 'foo"bar')],
|
||||
)
|
||||
)
|
||||
self.assertIn(""", result)
|
||||
self.assertNotIn('"foo"bar"', result)
|
||||
|
||||
def test_attributes_serialized_correctly(self):
|
||||
result = components.Component(
|
||||
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
||||
)
|
||||
)
|
||||
self.assertIn('class="foo"', result)
|
||||
self.assertIn('id="bar"', result)
|
||||
|
||||
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.assertNotIn(" <span", result)
|
||||
|
||||
def test_non_string_children_not_supported(self):
|
||||
"""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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -360,22 +406,22 @@ class IconTest(unittest.TestCase):
|
||||
"""Test Icon() component function."""
|
||||
|
||||
def test_valid_icon_renders_svg(self):
|
||||
result = components.Icon("play")
|
||||
result = str(components.Icon("play"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<svg", result)
|
||||
self.assertIn("</svg>", result)
|
||||
|
||||
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.assertIn("<svg", result)
|
||||
|
||||
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)
|
||||
|
||||
def test_returns_safetext(self):
|
||||
result = components.Icon("delete")
|
||||
result = str(components.Icon("delete"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
|
||||
@@ -383,17 +429,19 @@ class InputTest(unittest.TestCase):
|
||||
"""Test the Input() component."""
|
||||
|
||||
def test_input_default_type_text(self):
|
||||
result = components.Input()
|
||||
result = str(components.Input())
|
||||
self.assertIn("<input", result)
|
||||
self.assertIn('type="text"', result)
|
||||
|
||||
def test_input_custom_type(self):
|
||||
result = components.Input(type="submit")
|
||||
result = str(components.Input(type="submit"))
|
||||
self.assertIn('type="submit"', result)
|
||||
|
||||
def test_input_attributes_merged_with_type(self):
|
||||
result = components.Input(
|
||||
type="email", attributes=[("id", "email"), ("class", "form-input")]
|
||||
result = str(
|
||||
components.Input(
|
||||
type="email", attributes=[("id", "email"), ("class", "form-input")]
|
||||
)
|
||||
)
|
||||
self.assertIn('type="email"', result)
|
||||
self.assertIn('id="email"', result)
|
||||
@@ -404,12 +452,12 @@ class PopoverTruncatedTest(unittest.TestCase):
|
||||
"""Test PopoverTruncated() component function."""
|
||||
|
||||
def test_short_string_no_popover(self):
|
||||
result = components.PopoverTruncated("hi")
|
||||
result = str(components.PopoverTruncated("hi"))
|
||||
self.assertEqual(result, "hi")
|
||||
|
||||
def test_long_string_wrapped_in_popover(self):
|
||||
long_text = "a" * 100
|
||||
result = components.PopoverTruncated(long_text)
|
||||
result = str(components.PopoverTruncated(long_text))
|
||||
# Should NOT equal the truncated form directly
|
||||
truncated = components.truncate(long_text, 30)
|
||||
self.assertNotEqual(result, truncated)
|
||||
@@ -418,47 +466,55 @@ class PopoverTruncatedTest(unittest.TestCase):
|
||||
|
||||
def test_custom_ellipsis_used(self):
|
||||
long_text = "a" * 50
|
||||
result = components.PopoverTruncated(long_text, ellipsis=">>")
|
||||
result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
|
||||
# Django template escapes >> to >> in the wrapped_content
|
||||
self.assertIn(">>", result)
|
||||
|
||||
def test_popover_if_not_truncated_flag(self):
|
||||
short_text = "hi"
|
||||
result = components.PopoverTruncated(
|
||||
short_text, popover_content="full content", popover_if_not_truncated=True
|
||||
result = str(
|
||||
components.PopoverTruncated(
|
||||
short_text,
|
||||
popover_content="full content",
|
||||
popover_if_not_truncated=True,
|
||||
)
|
||||
)
|
||||
# Should be wrapped in popover even though short
|
||||
self.assertNotEqual(result, "hi")
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
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
|
||||
self.assertEqual(result, "short")
|
||||
|
||||
def test_popover_content_override_with_flag(self):
|
||||
result = components.PopoverTruncated(
|
||||
"short", popover_content="custom popover", popover_if_not_truncated=True
|
||||
result = str(
|
||||
components.PopoverTruncated(
|
||||
"short", popover_content="custom popover", popover_if_not_truncated=True
|
||||
)
|
||||
)
|
||||
self.assertIn("custom popover", result)
|
||||
|
||||
def test_endpart_visible_in_output(self):
|
||||
long_text = "a" * 50
|
||||
result = components.PopoverTruncated(long_text, endpart="...")
|
||||
result = str(components.PopoverTruncated(long_text, endpart="..."))
|
||||
self.assertIn("...", result)
|
||||
|
||||
def test_returns_safetext(self):
|
||||
result = components.PopoverTruncated("a" * 100)
|
||||
result = str(components.PopoverTruncated("a" * 100))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
def test_default_length(self):
|
||||
text = "a" * 31
|
||||
result = components.PopoverTruncated(text)
|
||||
result = str(components.PopoverTruncated(text))
|
||||
# 31 chars exceeds default length of 30, so should be truncated
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
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
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
@@ -490,7 +546,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
def test_name_with_icon_linkify_with_game(self):
|
||||
platform = self._create_platform(name="Steam", icon="steam")
|
||||
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.assertIn("<a ", result)
|
||||
self.assertIn("Test Game", result)
|
||||
@@ -499,7 +555,9 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
def test_name_with_icon_no_linkify(self):
|
||||
platform = self._create_platform(name="GOG", icon="gog")
|
||||
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.assertNotIn("<a ", 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",
|
||||
emulated=True,
|
||||
)
|
||||
result = components.NameWithIcon(session=session, linkify=True)
|
||||
result = str(components.NameWithIcon(session=session, linkify=True))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<a ", result)
|
||||
self.assertIn("Emulated", result)
|
||||
|
||||
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.assertIn("Standalone", result)
|
||||
|
||||
@@ -529,7 +587,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
game=game,
|
||||
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.assertIn("Epic Game", result)
|
||||
|
||||
@@ -537,7 +595,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
platform = self._create_platform()
|
||||
game = self._create_game(platform)
|
||||
purchase = self._create_purchase([game], price=29.99)
|
||||
result = components.PurchasePrice(purchase)
|
||||
result = str(components.PurchasePrice(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
# floatformat rounds to 1 decimal: 29.99 -> 30.0
|
||||
self.assertIn("30.0", result)
|
||||
@@ -548,7 +606,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
platform = self._create_platform(icon="steam")
|
||||
game = self._create_game(platform, name="Single Game")
|
||||
purchase = self._create_purchase([game], price=14.99)
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Single Game", result)
|
||||
self.assertIn("<a ", result)
|
||||
@@ -559,7 +617,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
game1 = self._create_game(platform, name="Game One")
|
||||
game2 = self._create_game(platform, name="Game Two")
|
||||
purchase = self._create_purchase([game1, game2], price=24.99)
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("2 games", result)
|
||||
self.assertIn("<a ", result)
|
||||
@@ -575,7 +633,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
)
|
||||
purchase.name = "Bundle"
|
||||
purchase.save()
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Bundle", result)
|
||||
|
||||
@@ -584,7 +642,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
game1 = self._create_game(platform, name="Alpha")
|
||||
game2 = self._create_game(platform, name="Beta")
|
||||
purchase = self._create_purchase([game1, game2], price=19.99)
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Alpha", result)
|
||||
self.assertIn("Beta", result)
|
||||
@@ -595,18 +653,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
|
||||
|
||||
def test_endpart_shorter_than_length(self):
|
||||
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
|
||||
self.assertIn("data-popover-target", result)
|
||||
self.assertIn("x", result)
|
||||
|
||||
def test_no_truncation_no_ellipsis(self):
|
||||
result = components.PopoverTruncated("short text")
|
||||
result = str(components.PopoverTruncated("short text"))
|
||||
self.assertEqual(result, "short text")
|
||||
|
||||
def test_custom_length(self):
|
||||
text = "hello world"
|
||||
result = components.PopoverTruncated(text, length=6)
|
||||
result = str(components.PopoverTruncated(text, length=6))
|
||||
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)
|
||||
|
||||
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.assertIn("Zelda", result)
|
||||
|
||||
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.assertIn("Unknown Game", result)
|
||||
|
||||
@@ -749,9 +809,11 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
def test_simple_table_renders_list_rows(self):
|
||||
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started", "Ended"],
|
||||
rows=[["Game1", "2025-01-01", "2025-03-01"]],
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started", "Ended"],
|
||||
rows=[["Game1", "2025-01-01", "2025-03-01"]],
|
||||
)
|
||||
)
|
||||
)
|
||||
tbody = self._tbody(result)
|
||||
@@ -774,9 +836,11 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
def test_simple_table_multiple_rows(self):
|
||||
"""Verify multiple rows all render."""
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
|
||||
)
|
||||
)
|
||||
)
|
||||
tbody = self._tbody(result)
|
||||
@@ -786,13 +850,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
|
||||
def test_simple_table_header_action_as_caption(self):
|
||||
"""Verify header_action renders inside <caption>."""
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["Game1", "2025-01-01"]],
|
||||
header_action=mark_safe('<a href="/add">Add</a>'),
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["Game1", "2025-01-01"]],
|
||||
header_action=components.Safe('<a href="/add">Add</a>'),
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertIn("<caption", result)
|
||||
@@ -802,15 +866,17 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
def test_simple_table_dict_rows_with_cell_data(self):
|
||||
"""Verify dict-style rows with row_id and cell_data render correctly."""
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Name", "Date"],
|
||||
rows=[
|
||||
{
|
||||
"row_id": "session-row-1",
|
||||
"hx_trigger": "device-changed",
|
||||
"cell_data": ["Game1", "2025-01-01"],
|
||||
}
|
||||
],
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Name", "Date"],
|
||||
rows=[
|
||||
{
|
||||
"row_id": "session-row-1",
|
||||
"hx_trigger": "device-changed",
|
||||
"cell_data": ["Game1", "2025-01-01"],
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
tbody = self._tbody(result)
|
||||
@@ -821,14 +887,12 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
self.assertIn("2025-01-01", tbody)
|
||||
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from common.components.primitives import Checkbox, Radio
|
||||
|
||||
|
||||
class ComponentPrimitivesTest(SimpleTestCase):
|
||||
def test_checkbox_primitive(self):
|
||||
html = Checkbox(
|
||||
name="test-check", label="Accept Terms", checked=True, value="yes"
|
||||
html = str(
|
||||
components.Checkbox(
|
||||
name="test-check", label="Accept Terms", checked=True, value="yes"
|
||||
)
|
||||
)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-check"', html)
|
||||
@@ -837,14 +901,18 @@ class ComponentPrimitivesTest(SimpleTestCase):
|
||||
self.assertIn("Accept Terms", html)
|
||||
|
||||
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.assertIn("<input", html)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-headless"', html)
|
||||
|
||||
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('name="test-radio"', html)
|
||||
self.assertIn('value="A"', html)
|
||||
@@ -867,6 +935,7 @@ class PrimitiveWidgetsTest(SimpleTestCase):
|
||||
|
||||
def test_primitive_checkbox_widget_renders_headless(self):
|
||||
from games.forms import PrimitiveCheckboxWidget
|
||||
|
||||
widget = PrimitiveCheckboxWidget()
|
||||
html = widget.render(name="agree", value=True)
|
||||
self.assertNotIn("<label", html)
|
||||
|
||||
@@ -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><b></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><b>x</b></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>"))), "<x><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()
|
||||
@@ -57,6 +57,22 @@ class RenderedPagesTest(TestCase):
|
||||
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 ------------------------------------------------------
|
||||
|
||||
def test_page_layout_wrapper(self):
|
||||
@@ -395,15 +411,12 @@ class PurchaseListDateFilterTest(TestCase):
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||
'value=""',
|
||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_date_refunded_not_null(self):
|
||||
response = self._get(
|
||||
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
|
||||
)
|
||||
response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
self.assertNotIn("EARLY-MARKER", html)
|
||||
|
||||
+106
-87
@@ -7,57 +7,62 @@ import django.test
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components import (
|
||||
FilterSelect,
|
||||
Pill,
|
||||
SearchSelect,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components import FilterSelect, Pill, SearchSelect
|
||||
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):
|
||||
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):
|
||||
html = Pill("hi")
|
||||
html = str(Pill("hi"))
|
||||
self.assertIn("data-pill", html)
|
||||
self.assertNotIn("data-pill-remove", html)
|
||||
|
||||
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('aria-label="Remove"', html)
|
||||
|
||||
def test_value_becomes_data_value(self):
|
||||
html = Pill("hi", value="42")
|
||||
html = str(Pill("hi", value="42"))
|
||||
self.assertIn('data-value="42"', html)
|
||||
|
||||
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):
|
||||
html = Pill("<b>x</b>")
|
||||
html = str(Pill("<b>x</b>"))
|
||||
self.assertIn("<b>", html)
|
||||
self.assertNotIn("<b>x</b>", html)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class SearchSelectComponentTest(unittest.TestCase):
|
||||
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):
|
||||
html = SearchSelect(name="games")
|
||||
html = str(SearchSelect(name="games"))
|
||||
self.assertIn("data-search-select-no-results", html)
|
||||
self.assertIn("No results", html)
|
||||
|
||||
def test_outer_container_carries_config(self):
|
||||
html = SearchSelect(
|
||||
name="games", search_url="/api/games/search", multi_select=True
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="games", search_url="/api/games/search", multi_select=True
|
||||
)
|
||||
)
|
||||
self.assertIn("data-search-select", html)
|
||||
self.assertIn('data-name="games"', html)
|
||||
@@ -65,10 +70,12 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn('data-multi="true"', html)
|
||||
|
||||
def test_multi_selected_renders_pills_and_hidden_inputs(self):
|
||||
html = SearchSelect(
|
||||
name="games",
|
||||
multi_select=True,
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="games",
|
||||
multi_select=True,
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
)
|
||||
)
|
||||
self.assertIn("data-pill", html)
|
||||
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
||||
@@ -78,9 +85,11 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertEqual(html.count(' name="games"'), 1)
|
||||
|
||||
def test_single_selected_has_no_pill_and_value_in_search_box(self):
|
||||
html = SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
)
|
||||
)
|
||||
# single-select renders no pill — the label lives in the search box
|
||||
self.assertNotIn("data-pill", html)
|
||||
@@ -90,20 +99,22 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertEqual(html.count(' name="games"'), 1)
|
||||
|
||||
def test_search_box_has_no_name(self):
|
||||
html = SearchSelect(name="games")
|
||||
html = str(SearchSelect(name="games"))
|
||||
self.assertIn("data-search-select-search", html)
|
||||
# container exposes data-name, never a submittable name on the search box
|
||||
self.assertEqual(html.count(' name="games"'), 0)
|
||||
|
||||
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-value="1"', html)
|
||||
self.assertIn("One", html)
|
||||
|
||||
def test_options_omitted_when_search_url_set(self):
|
||||
html = SearchSelect(
|
||||
name="t", options=[("1", "One")], search_url="/api/games/search"
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="t", options=[("1", "One")], search_url="/api/games/search"
|
||||
)
|
||||
)
|
||||
# No pre-rendered rows in the live panel; the row prototype lives only in
|
||||
# the cloneable <template>.
|
||||
@@ -114,7 +125,9 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
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
|
||||
# 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="pill"', 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):
|
||||
# The shared shell assembles the three regions in a fixed order; option
|
||||
# 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")
|
||||
search = html.index("data-search-select-search")
|
||||
options = html.index("data-search-select-options")
|
||||
@@ -135,11 +148,11 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
|
||||
def test_prefetch_attribute_and_defaults(self):
|
||||
# Default prefetch is 0 in SearchSelect
|
||||
html_default = SearchSelect(name="t")
|
||||
html_default = str(SearchSelect(name="t"))
|
||||
self.assertIn('data-prefetch="0"', html_default)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -147,10 +160,10 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
|
||||
|
||||
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):
|
||||
html = FilterSelect(field_name="type")
|
||||
html = str(FilterSelect(field_name="type"))
|
||||
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
||||
self.assertIn("data-search-select", html)
|
||||
self.assertIn('data-search-select-mode="filter"', html)
|
||||
@@ -159,17 +172,19 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertEqual(html.count(' name="type"'), 0)
|
||||
|
||||
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="exclude"', html)
|
||||
self.assertIn('data-value="g"', html)
|
||||
|
||||
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
|
||||
html = FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam"), ("2", "GOG")],
|
||||
included=[("1", "Steam")],
|
||||
excluded=[("2", "GOG")],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam"), ("2", "GOG")],
|
||||
included=[("1", "Steam")],
|
||||
excluded=[("2", "GOG")],
|
||||
)
|
||||
)
|
||||
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
|
||||
# symbol is a sibling text node.
|
||||
@@ -182,7 +197,7 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn("line-through", html) # excluded pill styling
|
||||
|
||||
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,
|
||||
# so the text filter leaves them visible.
|
||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||
@@ -191,27 +206,29 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
def test_modifier_pill_coexists_with_value_pills(self):
|
||||
"""Modifier and value pills both render server-side; the JS handles
|
||||
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
|
||||
html = FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam")],
|
||||
included=[("1", "Steam")],
|
||||
modifier="IS_NULL",
|
||||
modifier_options=self.MODIFIERS,
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam")],
|
||||
included=[("1", "Steam")],
|
||||
modifier="IS_NULL",
|
||||
modifier_options=self.MODIFIERS,
|
||||
)
|
||||
)
|
||||
# Both the modifier pill and the value pill render.
|
||||
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
||||
self.assertIn("(None)", html)
|
||||
self.assertIn(
|
||||
'data-search-select-type="include"', html
|
||||
) # value pill present
|
||||
self.assertIn('data-search-select-type="include"', html) # value pill present
|
||||
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
||||
|
||||
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
||||
html = FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
prefetch=20,
|
||||
modifier_options=self.MODIFIERS,
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
prefetch=20,
|
||||
modifier_options=self.MODIFIERS,
|
||||
)
|
||||
)
|
||||
# No value rows in the live panel (they're fetched); the row prototype
|
||||
# lives only in a <template>.
|
||||
@@ -225,10 +242,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
|
||||
def test_search_url_pills_use_resolved_labels(self):
|
||||
# A selected value outside the fetched window still shows its label.
|
||||
html = FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
||||
)
|
||||
)
|
||||
self.assertIn(">Obscure Game</span>", html)
|
||||
self.assertIn('data-value="4172"', html)
|
||||
@@ -241,40 +260,38 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
def test_m2m_modifiers_render_as_option_rows(self):
|
||||
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
|
||||
dropdown, not as a separate <select>."""
|
||||
html = FilterSelect(
|
||||
field_name="games",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("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
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="games",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("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)
|
||||
# No legacy match-mode <select>.
|
||||
self.assertNotIn("data-search-select-match", html)
|
||||
|
||||
def test_active_modifier_renders_pill(self):
|
||||
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
|
||||
(All) label alongside any value pills."""
|
||||
html = FilterSelect(
|
||||
field_name="games",
|
||||
modifier="INCLUDES_ALL",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="games",
|
||||
modifier="INCLUDES_ALL",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||
)
|
||||
)
|
||||
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
||||
self.assertIn("(All)", html)
|
||||
@@ -283,10 +300,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
|
||||
def test_presence_only_modifiers_no_m2m_rows(self):
|
||||
"""When modifier_options only has presence entries, no M2M rows appear."""
|
||||
html = FilterSelect(
|
||||
field_name="status",
|
||||
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||
options=[("f", "Finished")],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="status",
|
||||
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||
options=[("f", "Finished")],
|
||||
)
|
||||
)
|
||||
self.assertNotIn("INCLUDES_ALL", html)
|
||||
self.assertNotIn("INCLUDES_ONLY", html)
|
||||
|
||||
Reference in New Issue
Block a user