From 5f411b8ae94977f566d55bceb92261cb5ab26c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 14 Jun 2026 01:34:44 +0200 Subject: [PATCH] Try unifying 3 different element interfaces --- common/components/__init__.py | 6 ++- common/components/custom_elements.py | 40 ++++++++++--------- common/components/domain.py | 22 ++++------- common/components/primitives.py | 23 +++++++++-- games/static/js/add_session.js | 23 ----------- games/static/js/year_picker.js | 38 ++++++++++++++++++ games/views/game.py | 22 ++++------- games/views/session.py | 47 ++++++++--------------- tests/test_custom_elements.py | 10 ++--- tests/test_rendered_pages.py | 2 +- ts/elements/session-timestamp-buttons.ts | 49 ++++++++++++++++++++++++ 11 files changed, 170 insertions(+), 112 deletions(-) delete mode 100644 games/static/js/add_session.js create mode 100644 games/static/js/year_picker.js create mode 100644 ts/elements/session-timestamp-buttons.ts diff --git a/common/components/__init__.py b/common/components/__init__.py index 6e8795e..10e4bfb 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -18,7 +18,7 @@ from common.components.core import ( randomid, 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 ( DateRangeCalendar, DateRangeField, @@ -77,6 +77,7 @@ from common.components.primitives import ( Tr, Ul, YearPicker, + custom_element_builder, paginated_table_content, ) from common.components.search_select import ( @@ -92,8 +93,9 @@ from common.utils import truncate __all__ = [ "truncate", "BaseComponent", - "custom_element", "register_element", + "SessionTimestampButtons", + "custom_element_builder", "Element", "Fragment", "Media", diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index 0b42c7c..1ae485a 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -9,9 +9,10 @@ reader so drift fails ``tsc``. """ 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) @@ -33,22 +34,6 @@ def _kebab(name: str) -> str: return name.replace("_", "-") -def custom_element( - tag: str, props: Mapping[str, object], *, children: Children = None -) -> Node: - """Emit ``children`` and declare its compiled module. - - The module path mirrors the source layout: ``ts/elements/.ts`` compiles - to ``dist/elements/.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 ────────────────────────────────────────────────────────────────── _TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"} @@ -121,3 +106,22 @@ class PlayEventRowProps(TypedDict): 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") diff --git a/common/components/domain.py b/common/components/domain.py index e4e0873..68fa94b 100644 --- a/common/components/domain.py +++ b/common/components/domain.py @@ -228,9 +228,8 @@ _SELECTOR_OPTION_CLASS = ( def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: """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.custom_elements import GameStatusSelectorProps + from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps from common.components.primitives import Li, Ul options = [ @@ -266,18 +265,15 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: dropdown = Div( data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative" )[toggle, menu] - return custom_element( - "game-status-selector", - GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token), - children=[Div(class_="flex gap-2 items-center")[dropdown]], - ) + 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: """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.custom_elements import SessionDeviceSelectorProps + from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps from common.components.primitives import Li, Ul 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( data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative" )[toggle, menu] - return custom_element( - "session-device-selector", - SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token), - children=[Div(class_="flex gap-2 items-center")[dropdown]], - ) + return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[ + Div(class_="flex gap-2 items-center")[dropdown] + ] diff --git a/common/components/primitives.py b/common/components/primitives.py index 24c551c..808e5e9 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -68,8 +68,21 @@ def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]: return result -def _html_element(tag_name: str): - """Build a generic element builder for ``tag_name`` (the whitelist factory).""" +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/.ts`` → + ``dist/elements/.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, @@ -77,7 +90,8 @@ def _html_element(tag_name: str): **attrs: object, ) -> Element: 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.__doc__ = f"Builder for the <{tag_name}> element." @@ -245,8 +259,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 diff --git a/games/static/js/add_session.js b/games/static/js/add_session.js deleted file mode 100644 index 8effc41..0000000 --- a/games/static/js/add_session.js +++ /dev/null @@ -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"; - } - }); -} diff --git a/games/static/js/year_picker.js b/games/static/js/year_picker.js new file mode 100644 index 0000000..7eeb843 --- /dev/null +++ b/games/static/js/year_picker.js @@ -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(); + } +}); diff --git a/games/views/game.py b/games/views/game.py index 618f6a2..82a5917 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -354,7 +354,7 @@ _PLAYED_MENU = ( def _played_row(game: Game, request: HttpRequest) -> Node: """'Played N times' control as a custom element (ts/elements/play-event-row.ts).""" from common.components import Element, custom_element - from common.components.custom_elements import PlayEventRowProps + from common.components.custom_elements import PlayEventRowProps, _PlayEventRow played = game.playevents.count() @@ -397,19 +397,13 @@ def _played_row(game: Game, request: HttpRequest) -> Node: group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[ count_button, toggle_group ] - return custom_element( - "play-event-row", - PlayEventRowProps( - game_id=game.id, - csrf=get_token(request), - api_create_url=reverse("api-1.0.0:create_playevent"), - ), - children=[ - Div(class_="flex gap-2 items-center")[ - Span(class_="uppercase")["Played"], 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: diff --git a/games/views/session.py b/games/views/session.py index a00cd25..64d140b 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -11,12 +11,12 @@ 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, @@ -27,6 +27,7 @@ from common.components import ( SessionDeviceSelector, paginated_table_content, ) +from common.components import SessionTimestampButtons from common.components.primitives import Span, Td, Tr from common.layout import render_page from common.time import ( @@ -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", + )[ + Button(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", - ), + Button(data_target=field.name, data_type="toggle", size="xs")[ + "Toggle text" ], - ) + Button(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")), ) diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py index 23710dc..21c4df7 100644 --- a/tests/test_custom_elements.py +++ b/tests/test_custom_elements.py @@ -1,7 +1,7 @@ import unittest 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 ( ElementSpec, _ts_for_spec, @@ -17,9 +17,8 @@ class SampleProps(TypedDict): class CustomElementBuilderTest(unittest.TestCase): def test_serializes_props_to_kebab_attributes(self): - html = render( - custom_element("x-sample", {"game_id": 3, "status": "f"}, children=["hi"]) - ) + x_sample = custom_element_builder("x-sample") + html = render(x_sample(game_id=3, status="f")["hi"]) self.assertIn(" { + 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);