733da3419b
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
A multi-game Purchase is now treated as an *unsplittable* bundle (one price, whole-purchase refund). Independently-refundable multi-item orders (e.g. a Steam cart) are instead recorded as N separate single-game purchases, so per-game pricing and per-game refunds work with the existing single-purchase machinery — no through-model needed. Add-purchase form (single form, single endpoint): - 1 game: unchanged. - 2+ games: a "Separate price per game" toggle appears (default off = one bundle price). On, the bundle Price hides and one price input per game appears; the view creates one single-game Purchase each from price_for_game_<id>. `price` is now optional so combined mode still validates. Split action: - A Split button on multi-game purchase rows opens a confirmation modal that replaces the bundle with one single-game purchase per game (price split evenly, needs_price_update set), then HX-Redirects to the list. New general-purpose `selection-fields` custom element renders one synced form field per selected item of a source SearchSelect (consuming the existing search-select:change contract); it knows nothing about prices, so it is reusable. Behavior in ts/elements/selection-fields.ts. Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split icon, and unit + Playwright e2e coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
5.9 KiB
Python
182 lines
5.9 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")
|
|
|
|
|
|
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,
|
|
]
|