Files
timetracker/common/components/custom_elements.py
T
lukas 9960a8fc3e
Django CI/CD / build-and-push (push) Has been cancelled
Django CI/CD / test (push) Has been cancelled
feat(filters): replace RangeSlider with Stash-style NumberFilter (#85) (#86)
Numeric range filters could only express BETWEEN/GREATER_THAN/LESS_THAN
via the RangeSlider widget — no way to match NULL/missing values (the
original ask in #32) or exact/not-between. The criteria backend already
supported all 8 numeric modifiers + value2, so this is a UI swap.

- Add NumberFilter component, modeled 1:1 on StringFilter: an 8-modifier
  radio grid plus two number inputs, with the second input revealed only
  for BETWEEN/NOT_BETWEEN and both disabled for IS_NULL/NOT_NULL. Initial
  state is server-rendered so the widget never flashes.
- Migrate all 17 numeric range fields (game/session/purchase/playevent)
  to NumberFilter; drop the now-dead min/max aggregate queries.
- filter-bar.ts: serialize numberFields by modifier (mirroring textFields)
  and wire the modifier radios via event delegation on the persistent
  custom element so they survive htmx swaps of the inner bar body. Apply
  the same delegation fix to the existing string filters.
- Remove RangeSlider entirely: component, range-slider.ts, its custom
  element registration/props, and the range-slider e2e tests.

Backward compatible: old slider filters stored {value, value2, modifier},
the same JSON shape NumberFilter reads, so saved presets keep working.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:31:10 +02:00

226 lines
7.1 KiB
Python

"""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 Node
from common.components.primitives import (
Div,
Input,
Label,
Template,
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")
class SelectionFieldsProps(TypedDict):
source: str # data-name of the source SearchSelect to mirror
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
field_type: str # input type, e.g. "number"
min_items: int # render nothing until at least this many items are selected
active: bool # when false, render nothing (but preserve typed values)
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
_SelectionFields = custom_element_builder("selection-fields")
class DateRangePickerProps(TypedDict):
pass
register_element("date-range-picker", "DateRangePicker", DateRangePickerProps)
_DateRangePicker = custom_element_builder("date-range-picker")
class SearchSelectProps(TypedDict):
name: str
search_url: str
multi: bool
filter_mode: bool
free_text: bool
always_visible: bool
prefetch: int
sync_url: bool
register_element("search-select", "SearchSelect", SearchSelectProps)
_SearchSelect = custom_element_builder("search-select")
class FilterBarProps(TypedDict):
preset_list_url: str
preset_save_url: str
register_element("filter-bar", "FilterBar", FilterBarProps)
_FilterBarElement = custom_element_builder("filter-bar")
class YearPickerProps(TypedDict):
selected_year: str # "" for the all-time/empty state
available_years: str # csv, e.g. "2019,2020"
url_template: str # contains the literal __year__ placeholder
# The <year-picker> builder lives in primitives.py (next to YearPicker, which
# uses it) because custom_elements imports from primitives — registering here
# would be a circular import. Registration is codegen-only, so it belongs here.
register_element("year-picker", "YearPicker", YearPickerProps)
def SelectionFields(
*,
source: str,
name_prefix: str,
field_type: str = "text",
min_items: int = 1,
active: bool = False,
input_attributes: list[tuple[str, str]] | None = None,
) -> Node:
"""Render one synced form field per selected item of a source SearchSelect.
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
the global ``#add-form`` styling, so the markup stays minimal.
"""
row_template = Template(attributes=[("data-selection-fields-row", "")])[
Div(attributes=[("data-selection-fields-row-item", "")])[
Label(attributes=[("data-selection-fields-label", "")]),
Input(type=field_type, attributes=list(input_attributes or [])),
]
]
return _SelectionFields(
source=source,
name_prefix=name_prefix,
field_type=field_type,
min_items=min_items,
active="true" if active else "false",
)[
Div(attributes=[("data-selection-fields-rows", "")]),
row_template,
]