9960a8fc3e
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>
226 lines
7.1 KiB
Python
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,
|
|
]
|