Merge pull request #19 from KucharczykL/claude/custom-elements-experimental-unity

Try unifying 3 different element interfaces
This commit is contained in:
2026-06-14 11:50:46 +02:00
committed by GitHub
26 changed files with 321 additions and 234 deletions
+3
View File
@@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data
# CSRF trusted origins # CSRF trusted origins
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
# Create a default admin/admin superuser on startup (for initial setup only)
CREATE_DEFAULT_SUPERUSER=false
+2 -1
View File
@@ -14,6 +14,7 @@ dist/
.direnv .direnv
.hermes/ .hermes/
# TypeScript: compiled output and codegen are build-only # Build artifacts: generated in CI/Docker assets stage, not committed
/games/static/base.css
/games/static/js/dist/ /games/static/js/dist/
/ts/generated/ /ts/generated/
+1 -1
View File
@@ -26,7 +26,7 @@ FROM node:22-bookworm-slim AS assets
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile RUN npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
COPY . . COPY . .
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \ RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
+6 -4
View File
@@ -18,7 +18,7 @@ from common.components.core import (
randomid, randomid,
render, render,
) )
from common.components.custom_elements import custom_element, register_element from common.components.custom_elements import SessionTimestampButtons, register_element
from common.components.date_range_picker import ( from common.components.date_range_picker import (
DateRangeCalendar, DateRangeCalendar,
DateRangeField, DateRangeField,
@@ -48,7 +48,6 @@ from common.components.primitives import (
H1, H1,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Checkbox, Checkbox,
CsrfInput, CsrfInput,
@@ -68,6 +67,7 @@ from common.components.primitives import (
SimpleTable, SimpleTable,
Span, Span,
StaticScript, StaticScript,
StyledButton,
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
@@ -77,6 +77,7 @@ from common.components.primitives import (
Tr, Tr,
Ul, Ul,
YearPicker, YearPicker,
custom_element_builder,
paginated_table_content, paginated_table_content,
) )
from common.components.search_select import ( from common.components.search_select import (
@@ -92,8 +93,9 @@ from common.utils import truncate
__all__ = [ __all__ = [
"truncate", "truncate",
"BaseComponent", "BaseComponent",
"custom_element",
"register_element", "register_element",
"SessionTimestampButtons",
"custom_element_builder",
"Element", "Element",
"Fragment", "Fragment",
"Media", "Media",
@@ -107,7 +109,7 @@ __all__ = [
"randomid", "randomid",
"A", "A",
"AddForm", "AddForm",
"Button", "StyledButton",
"ButtonGroup", "ButtonGroup",
"Checkbox", "Checkbox",
"CsrfInput", "CsrfInput",
+22 -18
View File
@@ -9,9 +9,10 @@ reader so drift fails ``tsc``.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Mapping, TypedDict, get_type_hints from typing import TypedDict, get_type_hints
from common.components.core import Children, Element, HTMLAttribute, Media, Node from common.components.core import Media
from common.components.primitives import custom_element_builder
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -33,22 +34,6 @@ def _kebab(name: str) -> str:
return name.replace("_", "-") return name.replace("_", "-")
def custom_element(
tag: str, props: Mapping[str, object], *, children: Children = None
) -> Node:
"""Emit ``<tag kebab-attrs>children</tag>`` and declare its compiled module.
The module path mirrors the source layout: ``ts/elements/<tag>.ts`` compiles
to ``dist/elements/<tag>.js``, which ``Media`` loads via ``ModuleScript``."""
attributes: list[HTMLAttribute] = [
(_kebab(key), value) # type: ignore[misc]
for key, value in props.items()
]
return Element(tag, attributes, children).with_media(
Media(js=(f"dist/elements/{tag}.js",))
)
# ── Codegen ────────────────────────────────────────────────────────────────── # ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"} _TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
@@ -121,3 +106,22 @@ class PlayEventRowProps(TypedDict):
register_element("play-event-row", "PlayEventRow", PlayEventRowProps) register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
class SessionTimestampButtonsProps(TypedDict):
pass
register_element(
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
)
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
# Underscore-prefixed: used internally by domain wrappers.
# Public ones (no domain wrapper): exported directly.
_GameStatusSelector = custom_element_builder("game-status-selector")
_SessionDeviceSelector = custom_element_builder("session-device-selector")
_PlayEventRow = custom_element_builder("play-event-row")
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
+8 -14
View File
@@ -228,9 +228,8 @@ _SELECTOR_OPTION_CLASS = (
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts.""" """Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
from common.components import custom_element
from common.components.core import Element from common.components.core import Element
from common.components.custom_elements import GameStatusSelectorProps from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
from common.components.primitives import Li, Ul from common.components.primitives import Li, Ul
options = [ options = [
@@ -266,18 +265,15 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
dropdown = Div( dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative" data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu] )[toggle, menu]
return custom_element( return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
"game-status-selector", Div(class_="flex gap-2 items-center")[dropdown]
GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token), ]
children=[Div(class_="flex gap-2 items-center")[dropdown]],
)
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node: def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts.""" """Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
from common.components import custom_element
from common.components.core import Element from common.components.core import Element
from common.components.custom_elements import SessionDeviceSelectorProps from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
from common.components.primitives import Li, Ul from common.components.primitives import Li, Ul
current_name = session.device.name if session.device else "Unknown" current_name = session.device.name if session.device else "Unknown"
@@ -307,8 +303,6 @@ def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
dropdown = Div( dropdown = Div(
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative" data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
)[toggle, menu] )[toggle, menu]
return custom_element( return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
"session-device-selector", Div(class_="flex gap-2 items-center")[dropdown]
SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token), ]
children=[Div(class_="flex gap-2 items-center")[dropdown]],
)
+23 -35
View File
@@ -9,7 +9,6 @@ Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
@@ -68,8 +67,21 @@ def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
return result return result
def _html_element(tag_name: str): def custom_element_builder(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory).""" """Create a tag builder for a custom element with auto-attached Media.
The module path follows the convention ``ts/elements/<tag>.ts`` →
``dist/elements/<tag>.js``.
"""
return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
def _html_element(tag_name: str, media: Media | None = None):
"""Build a generic element builder for ``tag_name`` (the whitelist factory).
If ``media`` is provided, every node created by the builder will carry it
(used for custom elements whose compiled JS must be loaded automatically).
"""
def element( def element(
attributes: Attributes | None = None, attributes: Attributes | None = None,
@@ -77,13 +89,16 @@ def _html_element(tag_name: str):
**attrs: object, **attrs: object,
) -> Element: ) -> Element:
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs) merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
return Element(tag_name, merged, children) node = Element(tag_name, merged, children)
return node.with_media(media) if media else node
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:] element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element." element.__doc__ = f"Builder for the <{tag_name}> element."
return element return element
A = _html_element("a")
Button = _html_element("button")
Div = _html_element("div") Div = _html_element("div")
P = _html_element("p") P = _html_element("p")
Ul = _html_element("ul") Ul = _html_element("ul")
@@ -204,35 +219,7 @@ def PopoverTruncated(
return input_string return input_string
def A( def StyledButton(
attributes: Attributes | None = None,
children: Children = None,
url_name: str | None = None,
href: str | None = None,
) -> Element:
"""
Returns an anchor <a> tag.
Accepts one of two mutually-exclusive URL specifications:
- url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is
"""
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.")
additional_attributes = []
if url_name is not None:
additional_attributes = [("href", reverse(url_name))]
elif href is not None:
additional_attributes = [("href", href)]
return Element(
"a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: Attributes | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
size: str = "base", size: str = "base",
@@ -245,8 +232,9 @@ def Button(
title: str = "", title: str = "",
onclick: str = "", onclick: str = "",
name: str = "", name: str = "",
**attrs: object,
) -> Element: ) -> Element:
attributes = as_attributes(attributes) attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
children = children or [] children = children or []
# Separate custom class from other generic attributes # Separate custom class from other generic attributes
@@ -668,7 +656,7 @@ def AddForm(
children=[ children=[
CsrfInput(request), CsrfInput(request),
field_markup, field_markup,
Div(children=[Button(submit_attrs, "Submit", type="submit")]), Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
Div( Div(
[("class", "submit-button-container")], [("class", "submit-button-container")],
[additional_row] if additional_row else [], [additional_row] if additional_row else [],
+7 -2
View File
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
@@ -186,7 +187,7 @@ def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node": def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node":
"""Top navigation bar. """Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather Static chrome, so it's a single ``Safe`` node wrapping its markup rather
@@ -270,7 +271,10 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> "Node
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a> <a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li> </li>
<li> <li>
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a> <form method="post" action="{reverse("logout")}">
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
</form>
</li> </li>
</ul> </ul>
</div> </div>
@@ -309,6 +313,7 @@ def Page(
today_played=counts["today_played"], today_played=counts["today_played"],
last_7_played=counts["last_7_played"], last_7_played=counts["last_7_played"],
current_year=year, current_year=year,
csrf_token=get_token(request),
) )
messages = [ messages = [
+1
View File
@@ -12,6 +12,7 @@ services:
- PUID=${PUID:-1000} - PUID=${PUID:-1000}
- PGID=${PGID:-100} - PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data} - DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
ports: ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000" - "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes: volumes:
+51
View File
@@ -0,0 +1,51 @@
# Custom Element API: Two patterns, one goal
## Pattern 1: Named builder (current, preferred)
A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
```python
# definition (custom_elements.py)
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
# usage (session.py)
SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
]
```
**Pros:** explicit dependency, visible import, fails loudly if builder deleted
**Cons:** one line of ceremony per element
## Pattern 2: Element + registry (proposed, not implemented)
A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
```python
# definition (custom_elements.py)
register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
# usage (session.py) — no builder import needed
Element("session-timestamp-buttons",
[("class", "form-row-button-group"), ("hx-boost", "false")],
children=[...],
)
```
**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
## Recommendation
Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
## Quick reference
| Want | Write |
|------|-------|
| Plain HTML tag | `Div(class_="flex")["text"]` |
| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
| Builder from scratch | `custom_element_builder("tag-name")` |
+12
View File
@@ -20,4 +20,16 @@ chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate python manage.py migrate
python manage.py collectstatic --clear --no-input python manage.py collectstatic --clear --no-input
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', '', 'admin')
print('Created default superuser: admin / admin')
"
fi
chown -R "$PUID:$PGID" /home/timetracker/app/data
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
-23
View File
@@ -1,23 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
+38
View File
@@ -0,0 +1,38 @@
import { onSwap } from "./utils.js";
onSwap("#year-picker-input", function(pickerEl) {
const selectedYear = pickerEl.dataset.selectedYear;
const urlTemplate = pickerEl.dataset.urlTemplate;
const currentYear = new Date().getFullYear();
const availableYears = new Set(
pickerEl.dataset.availableYears
.split(",")
.map(s => parseInt(s.trim()))
.filter(n => !isNaN(n))
);
const picker = new Datepicker(pickerEl, {
pickLevel: 2,
format: "yyyy",
minDate: new Date(1999, 0, 1),
maxDate: new Date(currentYear, 11, 31),
autohide: false,
orientation: "bottom end",
showOnClick: false,
showOnFocus: false,
beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
});
pickerEl._pickerInstance = picker;
picker.element.addEventListener("changeDate", (event) => {
const year = event.detail.date?.getFullYear();
if (year && urlTemplate) {
window.location.href = urlTemplate.replace("__year__", year);
}
});
if (selectedYear) {
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
picker.update();
}
});
+7 -5
View File
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Icon,
paginated_table_content,
DeviceFilterBar, DeviceFilterBar,
Fragment,
Icon,
StyledButton,
paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
@@ -34,7 +34,9 @@ def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate(request, devices) devices, page_obj, elided_page_range = paginate(request, devices)
data = { data = {
"header_action": A([], Button([], "Add device"), url_name="games:add_device"), "header_action": A(href=reverse("games:add_device"))[
StyledButton()["Add device"]
],
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
+28 -38
View File
@@ -11,16 +11,15 @@ from django.urls import reverse
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components import ( from common.components import (
Fragment,
H1, H1,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
FilterBar, FilterBar,
Fragment,
GameStatus, GameStatus,
GameStatusSelector, GameStatusSelector,
Icon, Icon,
@@ -35,6 +34,7 @@ from common.components import (
Safe, Safe,
SearchField, SearchField,
SimpleTable, SimpleTable,
StyledButton,
Ul, Ul,
paginated_table_content, paginated_table_content,
) )
@@ -90,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
data = { data = {
"header_action": Div( "header_action": Div(
children=[ class_="flex justify-between",
SearchField(search_string=search_string), )[
A([], Button([], "Add game"), url_name="games:add_game"), SearchField(search_string=search_string),
], A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
attributes=[("class", "flex justify-between")], ],
),
"columns": [ "columns": [
"Name", "Name",
"Sort Name", "Sort Name",
@@ -172,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
AddForm( AddForm(
form, form,
request=request, request=request,
additional_row=Button( additional_row=StyledButton(
[], [],
"Submit & Create Purchase", "Submit & Create Purchase",
color="gray", color="gray",
@@ -248,14 +247,14 @@ def _delete_game_confirmation_modal(
Div( Div(
[("class", "items-center mt-5")], [("class", "items-center mt-5")],
[ [
Button( StyledButton(
[("class", "w-full")], [("class", "w-full")],
"Delete", "Delete",
color="red", color="red",
size="lg", size="lg",
type="submit", type="submit",
), ),
Button( StyledButton(
[("class", "mt-0 w-full")], [("class", "mt-0 w-full")],
"Cancel", "Cancel",
color="gray", color="gray",
@@ -353,26 +352,26 @@ _PLAYED_MENU = (
def _played_row(game: Game, request: HttpRequest) -> Node: def _played_row(game: Game, request: HttpRequest) -> Node:
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts).""" """'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
from common.components import Element, custom_element from common.components import Element
from common.components.custom_elements import PlayEventRowProps from common.components.custom_elements import _PlayEventRow
from common.components.primitives import Button
played: int = 0
played = game.playevents.count() played = game.playevents.count()
count_button = A(href=reverse("games:add_playevent"))[ count_button = A(href=reverse("games:add_playevent"))[
Element( Button(class_=_PLAYED_BTN + " rounded-s-lg")[
"button", Span(data_count="")[str(played)], " times"
[("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")], ]
[Span(data_count="")[str(played)], " times"],
)
] ]
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[ menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
Ul()[ Ul()[
Li(attributes=[("class", "px-4 py-2")])[ Li(class_="px-4 py-2")[
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[ A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
"Add playthrough..." "Add playthrough..."
] ]
], ],
Li(attributes=[("class", "px-4 py-2 cursor-pointer")])[ Li(class_="px-4 py-2 cursor-pointer")[
Element( Element(
"button", "button",
[("type", "button"), ("data-add-play", "")], [("type", "button"), ("data-add-play", "")],
@@ -397,19 +396,11 @@ def _played_row(game: Game, request: HttpRequest) -> Node:
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[ group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
count_button, toggle_group count_button, toggle_group
] ]
return custom_element( return _PlayEventRow(
"play-event-row", game_id=game.id,
PlayEventRowProps( csrf=get_token(request),
game_id=game.id, api_create_url=reverse("api-1.0.0:create_playevent"),
csrf=get_token(request), )[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
api_create_url=reverse("api-1.0.0:create_playevent"),
),
children=[
Div(class_="flex gap-2 items-center")[
Span(class_="uppercase")["Played"], group
]
],
)
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText: def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -693,10 +684,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
header_action = Div( header_action = Div(
children=[ children=[
A( A(href=reverse("games:add_session"))[
url_name="games:add_session", StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]), ],
),
A( A(
href=reverse( href=reverse(
"games:list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
@@ -705,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
children=Popover( children=Popover(
popover_content=last_session.game.name, popover_content=last_session.game.name,
children=[ children=[
Button( StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
+6 -6
View File
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Fragment,
Icon, Icon,
paginated_table_content,
PlatformFilterBar, PlatformFilterBar,
StyledButton,
paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
@@ -35,9 +35,9 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate(request, platforms) platforms, page_obj, elided_page_range = paginate(request, platforms)
data = { data = {
"header_action": A( "header_action": A(href=reverse("games:add_platform"))[
[], Button([], "Add platform"), url_name="games:add_platform" StyledButton()["Add platform"]
), ],
"columns": [ "columns": [
"Name", "Name",
"Icon", "Icon",
+6 -7
View File
@@ -9,17 +9,16 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Fragment,
Icon, Icon,
ModuleScript, ModuleScript,
paginated_table_content,
PlayEventFilterBar, PlayEventFilterBar,
StyledButton,
paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime from common.time import dateformat, format_duration, local_strftime
@@ -87,9 +86,9 @@ def create_playevent_tabledata(
for row in row_list for row in row_list
] ]
return { return {
"header_action": A( "header_action": A(href=reverse("games:add_playevent"))[
[], Button([], "Add play event"), url_name="games:add_playevent" StyledButton()["Add play event"]
), ],
"columns": list(filtered_column_list), "columns": list(filtered_column_list),
"rows": filtered_row_list, "rows": filtered_row_list,
} }
+8 -8
View File
@@ -14,14 +14,13 @@ from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
Fragment,
GameLink, GameLink,
Icon, Icon,
LinkedPurchase, LinkedPurchase,
@@ -30,6 +29,7 @@ from common.components import (
Node, Node,
PriceConverted, PriceConverted,
PurchasePrice, PurchasePrice,
StyledButton,
TableRow, TableRow,
paginated_table_content, paginated_table_content,
) )
@@ -110,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(request, purchases) purchases, page_obj, elided_page_range = paginate(request, purchases)
data = { data = {
"header_action": A( "header_action": A(href=reverse("games:add_purchase"))[
[], Button([], "Add purchase"), url_name="games:add_purchase" StyledButton()["Add purchase"]
), ],
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
@@ -153,7 +153,7 @@ def _purchase_additional_row() -> SafeText:
Td(), Td(),
Td( Td(
children=[ children=[
Button( StyledButton(
[], [],
"Submit & Create Session", "Submit & Create Session",
color="gray", color="gray",
@@ -319,14 +319,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
Div( Div(
[("class", "items-center mt-5")], [("class", "items-center mt-5")],
[ [
Button( StyledButton(
[("class", "w-full")], [("class", "w-full")],
"Refund", "Refund",
color="blue", color="blue",
size="lg", size="lg",
type="submit", type="submit",
), ),
Button( StyledButton(
[("class", "mt-0 w-full")], [("class", "mt-0 w-full")],
"Cancel", "Cancel",
color="gray", color="gray",
+23 -38
View File
@@ -11,12 +11,11 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button,
ButtonGroup, ButtonGroup,
Div, Div,
Fragment,
Icon, Icon,
ModuleScript, ModuleScript,
NameWithIcon, NameWithIcon,
@@ -25,6 +24,8 @@ from common.components import (
Safe, Safe,
SearchField, SearchField,
SessionDeviceSelector, SessionDeviceSelector,
SessionTimestampButtons,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import Span, Td, Tr from common.components.primitives import Span, Td, Tr
@@ -76,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
Div( Div(
children=[ children=[
A( A(
url_name="games:add_session", href=reverse("games:add_session"),
children=Button( )[
StyledButton(
icon=True, icon=True,
size="xs", size="xs",
children=[Icon("play"), "LOG"], )[Icon("play"), "LOG"]
), ],
),
A( A(
href=reverse( href=reverse(
"games:list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
@@ -91,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=Popover( children=Popover(
popover_content=last_session.game.name, popover_content=last_session.game.name,
children=[ children=[
Button( StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
this_side = "start" if field.name == "timestamp_start" else "end" this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start" other_side = "end" if field.name == "timestamp_start" else "start"
children.append( children.append(
Span( SessionTimestampButtons(
attributes=[ class_="form-row-button-group flex-row gap-3 justify-start mt-3",
( hx_boost="false",
"class", )[
"form-row-button-group flex-row gap-3 justify-start mt-3", StyledButton(data_target=field.name, data_type="now", size="xs")[
), "Set to now"
("hx-boost", "false"),
], ],
children=[ StyledButton(data_target=field.name, data_type="toggle", size="xs")[
Button( "Toggle text"
[("data-target", field.name), ("data-type", "now")],
"Set to now",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "toggle")],
"Toggle text",
size="xs",
),
Button(
[("data-target", field.name), ("data-type", "copy")],
f"Copy {this_side} value to {other_side}",
size="xs",
),
], ],
) StyledButton(data_target=field.name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
) )
rows.append(Div(children=children)) rows.append(Div(children=children))
return Fragment(*rows, separator="\n") return Fragment(*rows, separator="\n")
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session", title="Add New Session",
scripts=mark_safe( scripts=mark_safe(ModuleScript("search_select.js")),
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
) )
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session", title="Edit Session",
scripts=mark_safe( scripts=mark_safe(ModuleScript("search_select.js")),
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
) )
+4 -4
View File
@@ -9,6 +9,7 @@ from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from common.components import ( from common.components import (
A, A,
Div, Div,
@@ -100,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> Node:
else "text-body hover:text-heading underline decoration-dotted" else "text-body hover:text-heading underline decoration-dotted"
) )
alltime_btn = A( alltime_btn = A(
url_name="games:stats_alltime", href=reverse("games:stats_alltime"),
attributes=[("class", alltime_classes)], class_=alltime_classes,
children=["All-time stats"], )["All-time stats"]
)
picker = YearPicker( picker = YearPicker(
year=year_int, year=year_int,
available_years=tuple(year_range or []), available_years=tuple(year_range or []),
+3 -3
View File
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
from common.components import ( from common.components import (
A, A,
AddForm, AddForm,
Button,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
StyledButton,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import P from common.components.primitives import P
@@ -79,12 +79,12 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
P( P(
children=["Are you sure you want to delete this status change?"], children=["Are you sure you want to delete this status change?"],
), ),
Button( StyledButton(
[("class", "w-full")], "Delete", color="red", type="submit", size="lg" [("class", "w-full")], "Delete", color="red", type="submit", size="lg"
), ),
A( A(
[("class", "")], [("class", "")],
Button([("class", "w-full")], "Cancel", color="gray"), StyledButton([("class", "w-full")], "Cancel", color="gray"),
href=reverse("games:view_game", args=[statuschange.game.id]), href=reverse("games:view_game", args=[statuschange.game.id]),
), ),
], ],
-2
View File
@@ -1,2 +0,0 @@
allowBuilds:
'@parcel/watcher': false
+7 -19
View File
@@ -6,7 +6,7 @@ from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common import components from common import components
from games.models import Platform, Game, Purchase, Session from games.models import Game, Platform, Purchase, Session
# Component builders return lazy ``Node`` objects; these tests assert on rendered # Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site # HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
@@ -224,31 +224,18 @@ class ComponentReturnTypeTest(unittest.TestCase):
result = str(components.A([], "x", href="/literal/path")) result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result) self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self):
from unittest.mock import patch
with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = str(components.A([], "link", url_name="some_name"))
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self): def test_a_no_url_or_href(self):
result = str(components.A([], "link")) result = str(components.A([], "link"))
self.assertIn("<a>link</a>", result) self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result) self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
str(components.A(href="/path", url_name="some_name"))
def test_button_returns_safe_text(self): def test_button_returns_safe_text(self):
result = str(components.Button([], "click")) result = str(components.StyledButton([], "click"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<button", result) self.assertIn("<button", result)
def test_button_default_colors(self): def test_button_default_colors(self):
result = str(components.Button([], "click")) result = str(components.StyledButton([], "click"))
self.assertIn("text-white bg-brand", result) self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self): def test_name_with_icon_no_link(self):
@@ -269,7 +256,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self): def test_component_output_starts_with_tag(self):
for label, html in [ for label, html in [
("A", str(components.A(href="/foo", children=["link"]))), ("A", str(components.A(href="/foo", children=["link"]))),
("Button", str(components.Button([], "click"))), ("Button", str(components.StyledButton([], "click"))),
("Div", str(components.Div([], ["hello"]))), ("Div", str(components.Div([], ["hello"]))),
("Input", str(components.Input())), ("Input", str(components.Input())),
("ButtonGroup", str(components.ButtonGroup([]))), ("ButtonGroup", str(components.ButtonGroup([]))),
@@ -294,7 +281,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_button_with_icon_children_not_escaped(self): def test_button_with_icon_children_not_escaped(self):
result = str( result = str(
components.Button( components.StyledButton(
icon=True, icon=True,
size="xs", size="xs",
children=[components.Icon("play"), "LOG"], children=[components.Icon("play"), "LOG"],
@@ -307,7 +294,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
components.Popover( components.Popover(
popover_content="test tooltip", popover_content="test tooltip",
children=[ children=[
components.Button( components.StyledButton(
icon=True, icon=True,
color="gray", color="gray",
size="xs", size="xs",
@@ -923,6 +910,7 @@ class ComponentPrimitivesTest(SimpleTestCase):
class PrimitiveWidgetsTest(SimpleTestCase): class PrimitiveWidgetsTest(SimpleTestCase):
def test_mixin_applies_widget_to_boolean_fields_only(self): def test_mixin_applies_widget_to_boolean_fields_only(self):
from django import forms from django import forms
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
class DummyForm(PrimitiveWidgetsMixin, forms.Form): class DummyForm(PrimitiveWidgetsMixin, forms.Form):
+5 -5
View File
@@ -1,7 +1,7 @@
import unittest import unittest
from typing import TypedDict from typing import TypedDict
from common.components import custom_element, render from common.components import custom_element_builder, render
from common.components.custom_elements import ( from common.components.custom_elements import (
ElementSpec, ElementSpec,
_ts_for_spec, _ts_for_spec,
@@ -17,9 +17,8 @@ class SampleProps(TypedDict):
class CustomElementBuilderTest(unittest.TestCase): class CustomElementBuilderTest(unittest.TestCase):
def test_serializes_props_to_kebab_attributes(self): def test_serializes_props_to_kebab_attributes(self):
html = render( x_sample = custom_element_builder("x-sample")
custom_element("x-sample", {"game_id": 3, "status": "f"}, children=["hi"]) html = render(x_sample(game_id=3, status="f")["hi"])
)
self.assertIn("<x-sample", html) self.assertIn("<x-sample", html)
self.assertIn('game-id="3"', html) self.assertIn('game-id="3"', html)
self.assertIn('status="f"', html) self.assertIn('status="f"', html)
@@ -28,7 +27,8 @@ class CustomElementBuilderTest(unittest.TestCase):
def test_declares_compiled_module_media(self): def test_declares_compiled_module_media(self):
from common.components import collect_media from common.components import collect_media
node = custom_element("x-sample", {"game_id": 3}) x_sample = custom_element_builder("x-sample")
node = x_sample(game_id=3)
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",)) self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
+1 -1
View File
@@ -139,7 +139,7 @@ class RenderedPagesTest(TestCase):
def test_add_session_form_has_timestamp_helpers(self): def test_add_session_form_has_timestamp_helpers(self):
html = self.get("games:add_session").content.decode() html = self.get("games:add_session").content.decode()
self.assertIn("add_session.js", html) self.assertIn("session-timestamp-buttons", html)
for marker in [ for marker in [
"Set to now", "Set to now",
"Toggle text", "Toggle text",
+49
View File
@@ -0,0 +1,49 @@
// import { toISOUTCString } from "../../games/static/js/utils.js";
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
function toISOUTCString(date: Date): string {
function stringAndPad(number: number): string {
return number.toString().padStart(2, "0");
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
class SessionTimestampButtonsElement extends HTMLElement {
connectedCallback(): void {
for (const button of this.querySelectorAll("[data-target]")) {
const target = button.getAttribute("data-target");
const type = button.getAttribute("data-type");
if (!target || !type) continue;
const targetElement = document.querySelector(`#id_${target}`);
if (!(targetElement instanceof HTMLInputElement)) return;
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
const opposite = document.querySelector(`[name='${oppositeName}']`);
if (!(opposite instanceof HTMLInputElement)) return;
opposite.value = targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
}
}
customElements.define("session-timestamp-buttons", SessionTimestampButtonsElement);