Merge pull request #16 from KucharczykL/claude/custom-elements
This commit is contained in:
@@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
|
||||
# Create a default admin/admin superuser on startup (for initial setup only)
|
||||
CREATE_DEFAULT_SUPERUSER=false
|
||||
|
||||
@@ -19,6 +19,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install pnpm and JS dependencies
|
||||
run: npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build TypeScript
|
||||
run: make ts
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
|
||||
+5
-1
@@ -4,7 +4,6 @@ __pycache__
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
data/
|
||||
/static/
|
||||
@@ -13,3 +12,8 @@ dist/
|
||||
.python-version
|
||||
.direnv
|
||||
.hermes/
|
||||
|
||||
# Build artifacts: generated in CI/Docker assets stage, not committed
|
||||
/games/static/base.css
|
||||
/games/static/js/dist/
|
||||
/ts/generated/
|
||||
|
||||
@@ -124,6 +124,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
||||
- `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.
|
||||
|
||||
### Interactive components: custom elements + TypeScript
|
||||
|
||||
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
|
||||
|
||||
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/` → `games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
|
||||
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_`→`class`, `hx_get`→`hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
|
||||
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
|
||||
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||
|
||||
+21
@@ -15,6 +15,23 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
|
||||
# the assets stage below.
|
||||
RUN uv run python manage.py gen_element_types
|
||||
|
||||
|
||||
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
|
||||
# the compiled output ships in the image (dist/ is build-only, not committed).
|
||||
FROM node:22-bookworm-slim AS assets
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY . .
|
||||
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
|
||||
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
|
||||
&& pnpm exec tsc
|
||||
|
||||
|
||||
FROM python:3.14-slim-bookworm
|
||||
|
||||
@@ -44,6 +61,10 @@ WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
|
||||
@@ -25,12 +25,22 @@ init:
|
||||
server:
|
||||
uv run python -Wa manage.py runserver
|
||||
|
||||
gen-element-types:
|
||||
uv run python manage.py gen_element_types
|
||||
|
||||
ts: gen-element-types
|
||||
pnpm exec tsc
|
||||
|
||||
ts-check: gen-element-types
|
||||
pnpm exec tsc --noEmit
|
||||
|
||||
dev:
|
||||
@pnpm concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
--names "Django,Tailwind,TS" \
|
||||
--prefix-colors "blue,green,magenta" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
||||
"pnpm exec tsc --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
@@ -85,7 +95,7 @@ format:
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check test
|
||||
check: lint format-check ts-check test
|
||||
|
||||
date:
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
@@ -18,6 +18,7 @@ from common.components.core import (
|
||||
randomid,
|
||||
render,
|
||||
)
|
||||
from common.components.custom_elements import SessionTimestampButtons, register_element
|
||||
from common.components.date_range_picker import (
|
||||
DateRangeCalendar,
|
||||
DateRangeField,
|
||||
@@ -47,7 +48,6 @@ from common.components.primitives import (
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
CsrfInput,
|
||||
@@ -67,6 +67,7 @@ from common.components.primitives import (
|
||||
SimpleTable,
|
||||
Span,
|
||||
StaticScript,
|
||||
StyledButton,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
@@ -76,6 +77,7 @@ from common.components.primitives import (
|
||||
Tr,
|
||||
Ul,
|
||||
YearPicker,
|
||||
custom_element_builder,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
@@ -91,6 +93,9 @@ from common.utils import truncate
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"BaseComponent",
|
||||
"register_element",
|
||||
"SessionTimestampButtons",
|
||||
"custom_element_builder",
|
||||
"Element",
|
||||
"Fragment",
|
||||
"Media",
|
||||
@@ -104,7 +109,7 @@ __all__ = [
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"Button",
|
||||
"StyledButton",
|
||||
"ButtonGroup",
|
||||
"Checkbox",
|
||||
"CsrfInput",
|
||||
|
||||
@@ -235,6 +235,16 @@ class Element(Node):
|
||||
children = [children]
|
||||
self.children = children
|
||||
|
||||
def __getitem__(self, children: "Children | Node") -> "Element":
|
||||
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
|
||||
|
||||
Returns an Element with the same tag/attributes/media and these
|
||||
children, so the tree stays walkable (Media still bubbles)."""
|
||||
items = children if isinstance(children, tuple) else (children,)
|
||||
clone = Element(self.tag_name, self.attributes, list(items))
|
||||
clone.media = self.media
|
||||
return clone
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
media = self.media
|
||||
for child in self.children:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Custom-element builder, registry, and TypeScript codegen.
|
||||
|
||||
A custom element is a light-DOM Web Component: the Python builder emits a
|
||||
semantic tag whose typed props become kebab-case attributes and whose behavior
|
||||
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
|
||||
is the single source of truth for the server<->client contract;
|
||||
``gen_element_types`` turns each registered spec into a TS interface + attribute
|
||||
reader so drift fails ``tsc``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypedDict, get_type_hints
|
||||
|
||||
from common.components.core import Media
|
||||
from common.components.primitives import custom_element_builder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
tag: str # e.g. "game-status-selector"
|
||||
ts_name: str # e.g. "GameStatusSelector"
|
||||
props: type # a TypedDict subclass
|
||||
|
||||
|
||||
ELEMENT_REGISTRY: list[ElementSpec] = []
|
||||
|
||||
|
||||
def register_element(tag: str, ts_name: str, props: type) -> None:
|
||||
"""Register an element so codegen can emit its TS contract."""
|
||||
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
||||
|
||||
|
||||
def _kebab(name: str) -> str:
|
||||
return name.replace("_", "-")
|
||||
|
||||
|
||||
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||
|
||||
|
||||
def _camel(name: str) -> str:
|
||||
head, *tail = name.split("_")
|
||||
return head + "".join(part.title() for part in tail)
|
||||
|
||||
|
||||
def _reader_expr(name: str, python_type: type) -> str:
|
||||
attr = _kebab(name)
|
||||
if python_type in (int, float):
|
||||
return f'Number(el.getAttribute("{attr}"))'
|
||||
if python_type is bool:
|
||||
return f'el.getAttribute("{attr}") === "true"'
|
||||
return f'el.getAttribute("{attr}") ?? ""'
|
||||
|
||||
|
||||
def _ts_for_spec(spec: ElementSpec) -> str:
|
||||
hints = get_type_hints(spec.props)
|
||||
interface_lines = "\n".join(
|
||||
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
reader_lines = "\n".join(
|
||||
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
return (
|
||||
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
||||
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
||||
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
||||
)
|
||||
|
||||
|
||||
def render_props_module() -> str:
|
||||
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
||||
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
||||
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
||||
return header + "\n" + "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
# ── Element prop schemas (registered at import time) ─────────────────────────
|
||||
|
||||
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||
|
||||
|
||||
class SessionDeviceSelectorProps(TypedDict):
|
||||
session_id: int
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element(
|
||||
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||
)
|
||||
|
||||
|
||||
class PlayEventRowProps(TypedDict):
|
||||
game_id: int
|
||||
csrf: str
|
||||
api_create_url: str
|
||||
|
||||
|
||||
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")
|
||||
@@ -17,7 +17,6 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
||||
``games/static/js/date_range_picker.js``.
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
@@ -101,9 +100,7 @@ def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str
|
||||
return values
|
||||
|
||||
|
||||
def _segment_input(
|
||||
*, part: DatePartSpec, side: str, label: str, value: str
|
||||
) -> Node:
|
||||
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
|
||||
side_label = "from" if side == "min" else "to"
|
||||
return Input(
|
||||
attributes=[
|
||||
|
||||
+90
-121
@@ -209,131 +209,100 @@ def PurchasePrice(purchase) -> Node:
|
||||
)
|
||||
|
||||
|
||||
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}'\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||
f"</template>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
list_items = "\n".join(
|
||||
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': status === '{value}'}}\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
||||
f"</a></li>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
_SELECTOR_MENU_CLASS = (
|
||||
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
|
||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
|
||||
"border border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
_SELECTOR_TOGGLE_CLASS = (
|
||||
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
|
||||
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_SELECTOR_OPTION_CLASS = (
|
||||
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
|
||||
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
|
||||
"dark:hover:text-white border-0"
|
||||
)
|
||||
|
||||
return Safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
status: '{game.status}',
|
||||
status_display: '{game.get_status_display()}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setStatus(newStatus, newStatusDisplay) {{
|
||||
this.status = newStatus;
|
||||
this.status_display = newStatusDisplay;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ status: newStatus }})
|
||||
}})
|
||||
.then(() => {{
|
||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
console.error('Failed to update status');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{_dropdown_button_html(options_html, list_items)}
|
||||
</div>
|
||||
""")
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-option", ""),
|
||||
("data-value", str(value)),
|
||||
("class", _SELECTOR_OPTION_CLASS),
|
||||
],
|
||||
GameStatus(status=value, children=[label], display="flex"),
|
||||
)
|
||||
]
|
||||
for value, label in game_statuses
|
||||
]
|
||||
current_label = Span(data_label="")[
|
||||
GameStatus(
|
||||
status=game.status,
|
||||
children=[game.get_status_display()],
|
||||
display="flex",
|
||||
)
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
current_label, Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
|
||||
|
||||
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(
|
||||
"'", "\\'"
|
||||
)
|
||||
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
list_items = "\n".join(
|
||||
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
||||
for d in session_devices
|
||||
current_name = session.device.name if session.device else "Unknown"
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-option", ""),
|
||||
("data-value", str(device.id)),
|
||||
("class", _SELECTOR_OPTION_CLASS),
|
||||
],
|
||||
children=[device.name],
|
||||
)
|
||||
|
||||
return Safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
originalDeviceId: {device_id},
|
||||
originalDeviceName: '{device_name}',
|
||||
deviceId: {device_id},
|
||||
deviceName: '{device_name}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setDevice(newDeviceId, newDeviceName) {{
|
||||
this.deviceId = newDeviceId;
|
||||
this.deviceName = newDeviceName;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ device_id: newDeviceId }})
|
||||
}})
|
||||
.then((res) => {{
|
||||
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
this.deviceName = this.originalDeviceName;
|
||||
this.deviceId = this.originalDeviceId;
|
||||
console.error('Failed to update device');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{
|
||||
_dropdown_button_html(
|
||||
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
||||
)
|
||||
}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
||||
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
||||
return (
|
||||
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
|
||||
'<button type="button" @click="open = !open" '
|
||||
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
|
||||
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
|
||||
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
|
||||
'dark:focus:text-white align-middle hover:cursor-pointer">'
|
||||
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
|
||||
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
|
||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
|
||||
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
|
||||
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
|
||||
f"{list_items}"
|
||||
"</ul>"
|
||||
"</div>"
|
||||
"</button>"
|
||||
"</div>"
|
||||
]
|
||||
for device in session_devices
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
Span(data_label="")[current_name], Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
|
||||
@@ -173,9 +173,7 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _enum_filter(
|
||||
field_name: str, options, choice: FilterChoice, *, nullable
|
||||
) -> Node:
|
||||
def _enum_filter(field_name: str, options, choice: FilterChoice, *, nullable) -> 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
|
||||
|
||||
@@ -9,7 +9,6 @@ Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
@@ -25,6 +24,7 @@ from common.components.core import (
|
||||
Safe,
|
||||
as_attributes,
|
||||
as_children,
|
||||
collect_media,
|
||||
randomid,
|
||||
)
|
||||
from common.icons import get_icon
|
||||
@@ -52,20 +52,53 @@ _SIZE_CLASSES = {
|
||||
# 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 _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
|
||||
"""Translate htpy-style attribute kwargs to (name, value) pairs.
|
||||
|
||||
``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` ->
|
||||
``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute;
|
||||
``False`` / ``None`` -> omitted."""
|
||||
result: list[HTMLAttribute] = []
|
||||
for key, value in attrs.items():
|
||||
if value is None or value is False:
|
||||
continue
|
||||
name = key.rstrip("_").replace("_", "-")
|
||||
result.append((name, name if value is True else value)) # type: ignore[arg-type]
|
||||
return result
|
||||
|
||||
|
||||
def custom_element_builder(tag_name: str):
|
||||
"""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(
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
**attrs: object,
|
||||
) -> Element:
|
||||
return Element(tag_name, attributes, children)
|
||||
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
||||
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.__doc__ = f"Builder for the <{tag_name}> element."
|
||||
return element
|
||||
|
||||
|
||||
A = _html_element("a")
|
||||
Button = _html_element("button")
|
||||
Div = _html_element("div")
|
||||
P = _html_element("p")
|
||||
Ul = _html_element("ul")
|
||||
@@ -186,35 +219,7 @@ def PopoverTruncated(
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
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(
|
||||
def StyledButton(
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
size: str = "base",
|
||||
@@ -227,8 +232,9 @@ def Button(
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
**attrs: object,
|
||||
) -> Element:
|
||||
attributes = as_attributes(attributes)
|
||||
attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
||||
children = children or []
|
||||
|
||||
# Separate custom class from other generic attributes
|
||||
@@ -650,7 +656,7 @@ def AddForm(
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
field_markup,
|
||||
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||
Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
|
||||
Div(
|
||||
[("class", "submit-button-container")],
|
||||
[additional_row] if additional_row else [],
|
||||
@@ -971,15 +977,26 @@ def SimpleTable(
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
|
||||
# Rows/header are stringified into the table markup, so their components'
|
||||
# declared Media would be lost; collect it from the nodes first and attach
|
||||
# it to the returned node so Page() still emits each cell component's JS
|
||||
# (e.g. a <game-status-selector> in a cell).
|
||||
media = Media()
|
||||
|
||||
header_html = ""
|
||||
if header_action:
|
||||
header_html = str(TableHeader(children=[header_action]))
|
||||
header_node = TableHeader(children=[header_action])
|
||||
header_html = str(header_node)
|
||||
media = media + collect_media(header_node)
|
||||
|
||||
columns_html = "".join(
|
||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||
for col in columns
|
||||
)
|
||||
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||
row_nodes = [TableRow(data=row) for row in rows]
|
||||
rows_html = "".join(str(node) for node in row_nodes)
|
||||
for node in row_nodes:
|
||||
media = media + collect_media(node)
|
||||
|
||||
pagination_html = ""
|
||||
if page_obj and elided_page_range:
|
||||
@@ -995,7 +1012,8 @@ def SimpleTable(
|
||||
f"<tr>{columns_html}</tr></thead>"
|
||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"{rows_html}</tbody></table></div>"
|
||||
f"{pagination_html}</div>"
|
||||
f"{pagination_html}</div>",
|
||||
media=media,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+7
-2
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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>
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -309,6 +313,7 @@ def Page(
|
||||
today_played=counts["today_played"],
|
||||
last_7_played=counts["last_7_played"],
|
||||
current_year=year,
|
||||
csrf_token=get_token(request),
|
||||
)
|
||||
|
||||
messages = [
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-100}
|
||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
|
||||
ports:
|
||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||
volumes:
|
||||
|
||||
@@ -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")` |
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
# synchronous operations inside the async context safely.
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(browser_type_launch_args):
|
||||
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform, status="u")
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
host = page.locator("game-status-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
expect(host.locator("[data-menu]")).to_be_visible()
|
||||
with page.expect_response(
|
||||
lambda r: "/status" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator('[data-option][data-value="f"]').click()
|
||||
expect(host.locator("[data-menu]")).to_be_hidden()
|
||||
game.refresh_from_db()
|
||||
assert game.status == "f"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_device_selector_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Device, Game, Platform, Session
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
desktop = Device.objects.create(name="Desktop")
|
||||
deck = Device.objects.create(name="Deck")
|
||||
session = Session.objects.create(
|
||||
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
|
||||
)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
|
||||
|
||||
host = page.locator("session-device-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "/device" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator(f'[data-option][data-value="{deck.id}"]').click()
|
||||
session.refresh_from_db()
|
||||
assert session.device_id == deck.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_play_event_row_increments(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
|
||||
|
||||
host = page.locator("play-event-row").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
|
||||
):
|
||||
host.locator("[data-add-play]").click()
|
||||
expect(host.locator("[data-count]")).to_have_text("1")
|
||||
assert game.playevents.count() == 1
|
||||
@@ -121,9 +121,7 @@ def test_max_only_serializes_as_less_than(live_server, page):
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}
|
||||
}
|
||||
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
@@ -101,7 +97,9 @@ def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, pa
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# Locate handles
|
||||
max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]')
|
||||
max_handle = page.locator(
|
||||
'.range-handle-max[data-target="filter-session-count-max"]'
|
||||
)
|
||||
|
||||
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||
style = max_handle.get_attribute("style")
|
||||
|
||||
@@ -38,9 +38,7 @@ def prefilled_bar_view(request):
|
||||
"value": "Switch",
|
||||
"modifier": "INCLUDES",
|
||||
},
|
||||
"group": {
|
||||
"modifier": "IS_NULL"
|
||||
}
|
||||
"group": {"modifier": "IS_NULL"},
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||
@@ -73,7 +71,9 @@ def test_string_filter_defaults_and_toggles(live_server, page):
|
||||
|
||||
# 2. Enter values, click "includes" (INCLUDES), and submit
|
||||
name_input.fill("PlayStation")
|
||||
includes_radio = page.locator('input[name="filter-name-modifier"][value="INCLUDES"]')
|
||||
includes_radio = page.locator(
|
||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||
)
|
||||
includes_radio.click()
|
||||
|
||||
with page.expect_navigation():
|
||||
@@ -121,12 +121,16 @@ def test_string_filter_prefilled_states(live_server, page):
|
||||
# Verifies name matches "Switch" and "includes" is checked
|
||||
assert name_input.input_value() == "Switch"
|
||||
assert name_input.is_enabled()
|
||||
assert page.locator('input[name="filter-name-modifier"][value="INCLUDES"]').is_checked()
|
||||
assert page.locator(
|
||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||
).is_checked()
|
||||
|
||||
# Verifies group is empty, disabled, and "is null" is checked
|
||||
assert group_input.input_value() == ""
|
||||
assert not group_input.is_enabled()
|
||||
assert page.locator('input[name="filter-group-modifier"][value="IS_NULL"]').is_checked()
|
||||
assert page.locator(
|
||||
'input[name="filter-group-modifier"][value="IS_NULL"]'
|
||||
).is_checked()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -20,4 +20,16 @@ chown "$PUID:$PGID" /var/log/supervisor
|
||||
python manage.py migrate
|
||||
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
|
||||
|
||||
+3
-1
@@ -412,7 +412,9 @@ class GameFilter(OperatorFilter):
|
||||
from games.models import PlayEvent
|
||||
|
||||
event_q = criterion.to_q("note")
|
||||
matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True)
|
||||
matching_ids = PlayEvent.objects.filter(event_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
return Q(id__in=matching_ids)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Write ts/generated/props.ts from the registered custom-element specs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Importing the components package triggers element registration at import time.
|
||||
import common.components # noqa: F401
|
||||
from common.components.custom_elements import render_props_module
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate ts/generated/props.ts from registered custom elements."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = output_dir / "props.ts"
|
||||
target.write_text(render_props_module(), encoding="utf-8")
|
||||
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0017_add_filter_preset'),
|
||||
("games", "0017_add_filter_preset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Start'),
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(db_index=True, verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-13 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0018_alter_session_timestamp_start"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="filterpreset",
|
||||
name="mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
+10
-25
@@ -1790,6 +1790,9 @@
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1963,17 +1966,10 @@
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
border-bottom-right-radius: var(--radius-lg);
|
||||
}
|
||||
.rounded-tr-md {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
.rounded-b {
|
||||
border-bottom-right-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
.rounded-b-md {
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
@@ -1982,10 +1978,6 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
.border-0\! {
|
||||
border-style: var(--tw-border-style) !important;
|
||||
border-width: 0px !important;
|
||||
}
|
||||
.border-2 {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
@@ -2774,9 +2766,6 @@
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.no-underline\! {
|
||||
text-decoration-line: none !important;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -3162,6 +3151,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-700 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-gray-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-green-500 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -4200,17 +4196,6 @@
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
}
|
||||
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
||||
& li:first-of-type a {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
|
||||
& li:last-of-type a {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
.\[\&_td\:last-child\]\:text-right {
|
||||
& td:last-child {
|
||||
text-align: right;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Icon,
|
||||
paginated_table_content,
|
||||
DeviceFilterBar,
|
||||
Fragment,
|
||||
Icon,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
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)
|
||||
|
||||
data = {
|
||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||
"header_action": A(href=reverse("games:add_device"))[
|
||||
StyledButton()["Add device"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
|
||||
+74
-77
@@ -8,19 +8,18 @@ from django.middleware.csrf import get_token
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
FilterBar,
|
||||
Fragment,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
Icon,
|
||||
@@ -35,11 +34,11 @@ from common.components import (
|
||||
Safe,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
StyledButton,
|
||||
Ul,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Li, P, Span, Strong
|
||||
from common.icons import get_icon
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
@@ -91,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
class_="flex justify-between",
|
||||
)[
|
||||
SearchField(search_string=search_string),
|
||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
@@ -173,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
AddForm(
|
||||
form,
|
||||
request=request,
|
||||
additional_row=Button(
|
||||
additional_row=StyledButton(
|
||||
[],
|
||||
"Submit & Create Purchase",
|
||||
color="gray",
|
||||
@@ -249,14 +247,14 @@ def _delete_game_confirmation_modal(
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Delete",
|
||||
color="red",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -340,69 +338,69 @@ _STAT_SVGS = {
|
||||
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||
}
|
||||
|
||||
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
||||
<span class="uppercase">Played</span>
|
||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
||||
<a href="@@ADD_PE@@">
|
||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
<span x-text="played"></span> times
|
||||
</button>
|
||||
</a>
|
||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||
@@ARROWDOWN@@
|
||||
<div
|
||||
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||
x-show="open"
|
||||
>
|
||||
<ul
|
||||
class=""
|
||||
>
|
||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
||||
</li>
|
||||
<li
|
||||
x-on:click="createPlayEvent"
|
||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||
>
|
||||
Played times +1
|
||||
</li>
|
||||
<script>
|
||||
function createPlayEvent() {
|
||||
this.played++;
|
||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
||||
body: '{"game_id": @@GAME_ID@@}'
|
||||
})
|
||||
.catch(() => {
|
||||
this.played--;
|
||||
console.error('Failed to record play');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</ul>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>"""
|
||||
_PLAYED_BTN = (
|
||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_PLAYED_MENU = (
|
||||
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
||||
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
||||
"border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
|
||||
|
||||
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()),
|
||||
"@@ADD_PE@@": reverse("games:add_playevent"),
|
||||
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
||||
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
||||
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
||||
"@@CSRF@@": get_token(request),
|
||||
"@@GAME_ID@@": str(game.id),
|
||||
}
|
||||
html = _PLAYED_ROW_TEMPLATE
|
||||
for token, value in replacements.items():
|
||||
html = html.replace(token, value)
|
||||
return Safe(html)
|
||||
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
||||
from common.components import Element
|
||||
from common.components.custom_elements import _PlayEventRow
|
||||
from common.components.primitives import Button
|
||||
|
||||
played: int = 0
|
||||
played = game.playevents.count()
|
||||
|
||||
count_button = A(href=reverse("games:add_playevent"))[
|
||||
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
|
||||
Span(data_count="")[str(played)], " times"
|
||||
]
|
||||
]
|
||||
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
||||
Ul()[
|
||||
Li(class_="px-4 py-2")[
|
||||
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
|
||||
"Add playthrough..."
|
||||
]
|
||||
],
|
||||
Li(class_="px-4 py-2 cursor-pointer")[
|
||||
Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-add-play", "")],
|
||||
children=["Played times +1"],
|
||||
)
|
||||
],
|
||||
]
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-toggle", ""),
|
||||
("class", _PLAYED_BTN + " rounded-e-lg"),
|
||||
],
|
||||
[Icon("arrowdown")],
|
||||
)
|
||||
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
|
||||
# contain another <button>, and that invalid nesting makes the HTML parser
|
||||
# close ancestors early, ejecting later page sections from their container.
|
||||
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
|
||||
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
|
||||
count_button, toggle_group
|
||||
]
|
||||
return _PlayEventRow(
|
||||
game_id=game.id,
|
||||
csrf=get_token(request),
|
||||
api_create_url=reverse("api-1.0.0:create_playevent"),
|
||||
)[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:
|
||||
@@ -686,10 +684,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
|
||||
header_action = Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
|
||||
),
|
||||
A(href=reverse("games:add_session"))[
|
||||
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
|
||||
],
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -698,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -784,7 +781,7 @@ def _history_section(game: Game) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
_GET_SESSION_COUNT_SCRIPT = mark_safe(
|
||||
_GET_SESSION_COUNT_SCRIPT = Safe(
|
||||
"<script>\n"
|
||||
" function getSessionCount() {\n"
|
||||
" return document.getElementById('session-count')"
|
||||
|
||||
@@ -4,14 +4,14 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fragment,
|
||||
Icon,
|
||||
paginated_table_content,
|
||||
PlatformFilterBar,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
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)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
[], Button([], "Add platform"), url_name="games:add_platform"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_platform"))[
|
||||
StyledButton()["Add platform"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Icon",
|
||||
|
||||
@@ -9,17 +9,16 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fragment,
|
||||
Icon,
|
||||
ModuleScript,
|
||||
paginated_table_content,
|
||||
PlayEventFilterBar,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, format_duration, local_strftime
|
||||
@@ -87,9 +86,9 @@ def create_playevent_tabledata(
|
||||
for row in row_list
|
||||
]
|
||||
return {
|
||||
"header_action": A(
|
||||
[], Button([], "Add play event"), url_name="games:add_playevent"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_playevent"))[
|
||||
StyledButton()["Add play event"]
|
||||
],
|
||||
"columns": list(filtered_column_list),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
|
||||
@@ -14,14 +14,13 @@ 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,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
Fragment,
|
||||
GameLink,
|
||||
Icon,
|
||||
LinkedPurchase,
|
||||
@@ -30,6 +29,7 @@ from common.components import (
|
||||
Node,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
StyledButton,
|
||||
TableRow,
|
||||
paginated_table_content,
|
||||
)
|
||||
@@ -110,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_purchase"))[
|
||||
StyledButton()["Add purchase"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -153,7 +153,7 @@ def _purchase_additional_row() -> SafeText:
|
||||
Td(),
|
||||
Td(
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
[],
|
||||
"Submit & Create Session",
|
||||
color="gray",
|
||||
@@ -319,14 +319,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Refund",
|
||||
color="blue",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
|
||||
+23
-38
@@ -11,12 +11,11 @@ from django.utils import timezone
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
Fragment,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Div,
|
||||
Fragment,
|
||||
Icon,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
@@ -25,6 +24,8 @@ from common.components import (
|
||||
Safe,
|
||||
SearchField,
|
||||
SessionDeviceSelector,
|
||||
SessionTimestampButtons,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Span, Td, Tr
|
||||
@@ -76,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
href=reverse("games:add_session"),
|
||||
)[
|
||||
StyledButton(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
),
|
||||
)[Icon("play"), "LOG"]
|
||||
],
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -91,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
|
||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||
children.append(
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
),
|
||||
("hx-boost", "false"),
|
||||
SessionTimestampButtons(
|
||||
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
hx_boost="false",
|
||||
)[
|
||||
StyledButton(data_target=field.name, data_type="now", size="xs")[
|
||||
"Set to now"
|
||||
],
|
||||
children=[
|
||||
Button(
|
||||
[("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="toggle", size="xs")[
|
||||
"Toggle text"
|
||||
],
|
||||
)
|
||||
StyledButton(data_target=field.name, data_type="copy", size="xs")[
|
||||
f"Copy {this_side} value to {other_side}"
|
||||
],
|
||||
]
|
||||
)
|
||||
rows.append(Div(children=children))
|
||||
return Fragment(*rows, separator="\n")
|
||||
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Add New Session",
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
||||
),
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
)
|
||||
|
||||
|
||||
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Edit Session",
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
||||
),
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 common.components import (
|
||||
A,
|
||||
Div,
|
||||
@@ -100,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> Node:
|
||||
else "text-body hover:text-heading underline decoration-dotted"
|
||||
)
|
||||
alltime_btn = A(
|
||||
url_name="games:stats_alltime",
|
||||
attributes=[("class", alltime_classes)],
|
||||
children=["All-time stats"],
|
||||
)
|
||||
href=reverse("games:stats_alltime"),
|
||||
class_=alltime_classes,
|
||||
)["All-time stats"]
|
||||
picker = YearPicker(
|
||||
year=year_int,
|
||||
available_years=tuple(year_range or []),
|
||||
|
||||
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import P
|
||||
@@ -79,12 +79,12 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
||||
P(
|
||||
children=["Are you sure you want to delete this status change?"],
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
||||
),
|
||||
A(
|
||||
[("class", "")],
|
||||
Button([("class", "w-full")], "Cancel", color="gray"),
|
||||
StyledButton([("class", "w-full")], "Cancel", color="gray"),
|
||||
href=reverse("games:view_game", args=[statuschange.game.id]),
|
||||
),
|
||||
],
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"concurrently": "^8.2.2",
|
||||
"npm-check-updates": "^16.14.20",
|
||||
"tailwindcss": "^4.1.18"
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
|
||||
Generated
+3329
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
from games.models import Game, Platform, 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
|
||||
@@ -224,31 +224,18 @@ class ComponentReturnTypeTest(unittest.TestCase):
|
||||
result = str(components.A([], "x", href="/literal/path"))
|
||||
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):
|
||||
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):
|
||||
str(components.A(href="/path", url_name="some_name"))
|
||||
|
||||
def test_button_returns_safe_text(self):
|
||||
result = str(components.Button([], "click"))
|
||||
result = str(components.StyledButton([], "click"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<button", result)
|
||||
|
||||
def test_button_default_colors(self):
|
||||
result = str(components.Button([], "click"))
|
||||
result = str(components.StyledButton([], "click"))
|
||||
self.assertIn("text-white bg-brand", result)
|
||||
|
||||
def test_name_with_icon_no_link(self):
|
||||
@@ -269,7 +256,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
def test_component_output_starts_with_tag(self):
|
||||
for label, html in [
|
||||
("A", str(components.A(href="/foo", children=["link"]))),
|
||||
("Button", str(components.Button([], "click"))),
|
||||
("Button", str(components.StyledButton([], "click"))),
|
||||
("Div", str(components.Div([], ["hello"]))),
|
||||
("Input", str(components.Input())),
|
||||
("ButtonGroup", str(components.ButtonGroup([]))),
|
||||
@@ -294,7 +281,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
|
||||
def test_button_with_icon_children_not_escaped(self):
|
||||
result = str(
|
||||
components.Button(
|
||||
components.StyledButton(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "LOG"],
|
||||
@@ -307,7 +294,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
components.Popover(
|
||||
popover_content="test tooltip",
|
||||
children=[
|
||||
components.Button(
|
||||
components.StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -923,6 +910,7 @@ class ComponentPrimitivesTest(SimpleTestCase):
|
||||
class PrimitiveWidgetsTest(SimpleTestCase):
|
||||
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
||||
from django import forms
|
||||
|
||||
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
||||
|
||||
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
from typing import TypedDict
|
||||
|
||||
from common.components import custom_element_builder, render
|
||||
from common.components.custom_elements import (
|
||||
ElementSpec,
|
||||
_ts_for_spec,
|
||||
register_element,
|
||||
)
|
||||
|
||||
|
||||
class SampleProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
is_on: bool
|
||||
|
||||
|
||||
class CustomElementBuilderTest(unittest.TestCase):
|
||||
def test_serializes_props_to_kebab_attributes(self):
|
||||
x_sample = custom_element_builder("x-sample")
|
||||
html = render(x_sample(game_id=3, status="f")["hi"])
|
||||
self.assertIn("<x-sample", html)
|
||||
self.assertIn('game-id="3"', html)
|
||||
self.assertIn('status="f"', html)
|
||||
self.assertIn(">hi</x-sample>", html)
|
||||
|
||||
def test_declares_compiled_module_media(self):
|
||||
from common.components import collect_media
|
||||
|
||||
x_sample = custom_element_builder("x-sample")
|
||||
node = x_sample(game_id=3)
|
||||
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
||||
|
||||
|
||||
class CodegenTest(unittest.TestCase):
|
||||
def test_emits_interface_and_reader(self):
|
||||
spec = ElementSpec("x-sample", "XSample", SampleProps)
|
||||
ts = _ts_for_spec(spec)
|
||||
self.assertIn("export interface XSampleProps {", ts)
|
||||
self.assertIn("gameId: number;", ts)
|
||||
self.assertIn("status: string;", ts)
|
||||
self.assertIn("isOn: boolean;", ts)
|
||||
self.assertIn(
|
||||
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
|
||||
)
|
||||
self.assertIn('Number(el.getAttribute("game-id"))', ts)
|
||||
self.assertIn('el.getAttribute("status") ?? ""', ts)
|
||||
self.assertIn('el.getAttribute("is-on") === "true"', ts)
|
||||
|
||||
|
||||
class RegistryTest(unittest.TestCase):
|
||||
def test_register_adds_spec(self):
|
||||
from common.components.custom_elements import ELEMENT_REGISTRY
|
||||
|
||||
before = len(ELEMENT_REGISTRY)
|
||||
register_element("x-reg-test", "XRegTest", SampleProps)
|
||||
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
|
||||
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
|
||||
|
||||
|
||||
class GameStatusSelectorRenderTest(unittest.TestCase):
|
||||
def test_emits_tag_props_and_media(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from common.components import GameStatusSelector, collect_media, render
|
||||
|
||||
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
|
||||
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
|
||||
html = render(node)
|
||||
self.assertIn("<game-status-selector", html)
|
||||
self.assertIn('game-id="7"', html)
|
||||
self.assertIn('status="f"', html)
|
||||
self.assertIn('csrf="tok"', html)
|
||||
self.assertIn("data-option", html)
|
||||
self.assertIn('data-value="u"', html)
|
||||
self.assertNotIn("x-data", html) # no Alpine left
|
||||
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
|
||||
|
||||
|
||||
class SessionDeviceSelectorRenderTest(unittest.TestCase):
|
||||
def test_emits_tag_and_options(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from common.components import SessionDeviceSelector, render
|
||||
|
||||
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
|
||||
devices = [
|
||||
SimpleNamespace(id=1, name="Desktop"),
|
||||
SimpleNamespace(id=2, name="Deck"),
|
||||
]
|
||||
html = render(SessionDeviceSelector(session, devices, "tok"))
|
||||
self.assertIn("<session-device-selector", html)
|
||||
self.assertIn('session-id="4"', html)
|
||||
self.assertIn('data-value="2"', html)
|
||||
self.assertNotIn("x-data", html)
|
||||
@@ -362,4 +362,3 @@ class FilterBarRenderingTest(TestCase):
|
||||
self.assertIn('name="filter-refunded"', purchase_html)
|
||||
self.assertIn('value="true"', purchase_html)
|
||||
self.assertIn('value="false"', purchase_html)
|
||||
|
||||
|
||||
@@ -85,4 +85,3 @@ class ParseBoolNullableTest(SimpleTestCase):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||
|
||||
|
||||
+10
-4
@@ -560,8 +560,14 @@ class TestFilterBarRendering:
|
||||
|
||||
def test_mastered_not_checked_by_default(self):
|
||||
html = str(FilterBar(filter_json=""))
|
||||
assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||
assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||
assert (
|
||||
'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
||||
not in html
|
||||
)
|
||||
assert (
|
||||
'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
||||
not in html
|
||||
)
|
||||
|
||||
def test_mastered_checked_when_filtered(self):
|
||||
html = str(
|
||||
@@ -784,7 +790,7 @@ class TestExpandedFiltersAgainstDB:
|
||||
from games.filters import SessionFilter
|
||||
from games.models import Session
|
||||
|
||||
data = self._setup_entities()
|
||||
self._setup_entities()
|
||||
|
||||
# Test duration_total_hours equals 4
|
||||
sf_tot = SessionFilter.from_json(
|
||||
@@ -808,7 +814,7 @@ class TestExpandedFiltersAgainstDB:
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
data = self._setup_entities()
|
||||
self._setup_entities()
|
||||
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
|
||||
@@ -174,5 +174,48 @@ class RealComponentMediaTest(unittest.TestCase):
|
||||
self.assertIn("range_slider.js", media.js)
|
||||
|
||||
|
||||
class HtpyStyleSugarTest(unittest.TestCase):
|
||||
def test_getitem_sets_children(self):
|
||||
from common.components import Div, Span
|
||||
|
||||
self.assertEqual(
|
||||
render(Div(class_="card")[Span()["hi"]]),
|
||||
'<div class="card"><span>hi</span></div>',
|
||||
)
|
||||
|
||||
def test_getitem_multiple_children(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
|
||||
|
||||
def test_kwargs_class_underscore_becomes_class(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertIn('class="x"', render(Div(class_="x")))
|
||||
|
||||
def test_kwargs_inner_underscore_becomes_hyphen(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertIn('hx-get="/y"', render(Div(hx_get="/y")))
|
||||
|
||||
def test_kwargs_true_renders_bare_attr(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertIn('hidden="hidden"', render(Div(hidden=True)))
|
||||
|
||||
def test_kwargs_false_and_none_omitted(self):
|
||||
from common.components import Div
|
||||
|
||||
html = render(Div(hidden=False, title=None))
|
||||
self.assertNotIn("hidden", html)
|
||||
self.assertNotIn("title", html)
|
||||
|
||||
def test_getitem_preserves_media(self):
|
||||
from common.components import Div, Media, collect_media
|
||||
|
||||
node = Div(class_="x").with_media(Media(js=("a.js",)))["child"]
|
||||
self.assertEqual(collect_media(node).js, ("a.js",))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -139,7 +139,7 @@ class RenderedPagesTest(TestCase):
|
||||
|
||||
def test_add_session_form_has_timestamp_helpers(self):
|
||||
html = self.get("games:add_session").content.decode()
|
||||
self.assertIn("add_session.js", html)
|
||||
self.assertIn("session-timestamp-buttons", html)
|
||||
for marker in [
|
||||
"Set to now",
|
||||
"Toggle text",
|
||||
@@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase):
|
||||
"Platform",
|
||||
'id="history-container"',
|
||||
"status-changed from:body",
|
||||
"createPlayEvent", # the played-row Alpine dropdown script
|
||||
"<play-event-row", # the played-row custom element
|
||||
'hx-target="#global-modal-container"', # delete trigger
|
||||
"Purchases",
|
||||
"Sessions",
|
||||
@@ -179,6 +179,14 @@ class RenderedPagesTest(TestCase):
|
||||
self.assertNoEscapedTags(html)
|
||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||
|
||||
def test_view_game_uses_play_event_row_element(self):
|
||||
game = Game.objects.create(name="Played Game", platform=self.platform)
|
||||
html = self.get("games:view_game", game.id).content.decode()
|
||||
self.assertIn("<play-event-row", html)
|
||||
self.assertIn('game-id="', html)
|
||||
self.assertNotIn("@@", html) # token-replace hack gone
|
||||
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
|
||||
|
||||
def test_view_game_empty_sections(self):
|
||||
"""A game with no sessions/purchases/etc shows the empty messages."""
|
||||
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface DropdownConfig {
|
||||
patchUrl: string;
|
||||
bodyKey: string; // server field name, e.g. "status" or "device_id"
|
||||
event: string; // dispatched on document.body after a successful PATCH
|
||||
csrf: string;
|
||||
numericValue?: boolean; // parse the option value as a number
|
||||
}
|
||||
|
||||
// Wires a light-DOM value-selector dropdown that lives inside `host`.
|
||||
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
|
||||
// [data-label], and one or more [data-option][data-value].
|
||||
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
|
||||
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
|
||||
const menu = host.querySelector<HTMLElement>("[data-menu]");
|
||||
const label = host.querySelector<HTMLElement>("[data-label]");
|
||||
if (!toggle || !menu || !label) return;
|
||||
|
||||
const close = () => {
|
||||
menu.hidden = true;
|
||||
};
|
||||
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!host.contains(event.target as Node)) close();
|
||||
});
|
||||
|
||||
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
|
||||
option.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const raw = option.dataset.value ?? "";
|
||||
label.innerHTML = option.innerHTML;
|
||||
close();
|
||||
const body: Record<string, unknown> = {
|
||||
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
|
||||
};
|
||||
window
|
||||
.fetchWithHtmxTriggers(config.patchUrl, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
|
||||
.catch(() => console.error("Failed to update", config.patchUrl));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readGameStatusSelectorProps } from "../generated/props.js";
|
||||
import { initDropdown } from "./dropdown.js";
|
||||
|
||||
class GameStatusSelectorElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const props = readGameStatusSelectorProps(this);
|
||||
initDropdown(this, {
|
||||
patchUrl: `/api/games/${props.gameId}/status`,
|
||||
bodyKey: "status",
|
||||
event: "status-changed",
|
||||
csrf: props.csrf,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("game-status-selector", GameStatusSelectorElement);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readPlayEventRowProps } from "../generated/props.js";
|
||||
|
||||
class PlayEventRowElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const props = readPlayEventRowProps(this);
|
||||
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
|
||||
const menu = this.querySelector<HTMLElement>("[data-menu]");
|
||||
const count = this.querySelector<HTMLElement>("[data-count]");
|
||||
const addPlay = this.querySelector<HTMLElement>("[data-add-play]");
|
||||
if (!toggle || !menu) return;
|
||||
|
||||
const close = () => {
|
||||
menu.hidden = true;
|
||||
};
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!this.contains(event.target as Node)) close();
|
||||
});
|
||||
|
||||
addPlay?.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (count) count.textContent = String(Number(count.textContent) + 1);
|
||||
close();
|
||||
window
|
||||
.fetchWithHtmxTriggers(props.apiCreateUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf },
|
||||
body: JSON.stringify({ game_id: props.gameId }),
|
||||
})
|
||||
.catch(() => {
|
||||
if (count) count.textContent = String(Number(count.textContent) - 1);
|
||||
console.error("Failed to record play");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("play-event-row", PlayEventRowElement);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readSessionDeviceSelectorProps } from "../generated/props.js";
|
||||
import { initDropdown } from "./dropdown.js";
|
||||
|
||||
class SessionDeviceSelectorElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const props = readSessionDeviceSelectorProps(this);
|
||||
initDropdown(this, {
|
||||
patchUrl: `/api/session/${props.sessionId}/device`,
|
||||
bodyKey: "device_id",
|
||||
event: "device-changed",
|
||||
csrf: props.csrf,
|
||||
numericValue: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("session-device-selector", SessionDeviceSelectorElement);
|
||||
@@ -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);
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmitOnError": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"rootDir": "ts",
|
||||
"outDir": "games/static/js/dist"
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user