Introduce LabeledOption and RangeValues named types
Replace all tuple[str, str] annotations with purpose-specific names: - LabeledOption = tuple[str, str] for (value, label) pairs used in FilterChoice, FilterSelect params, _modifier_options, _find_label, and _extract_labeled. - RangeValues(min, max) NamedTuple for _parse_range return values, making the two fields self-documenting at every call site. Export LabeledOption from common.components alongside SearchSelectOption. Document the "name compound types explicitly" convention in CLAUDE.md. https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
This commit is contained in:
@@ -167,3 +167,4 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
|
|||||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||||
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
||||||
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
||||||
|
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from common.components.primitives import (
|
|||||||
)
|
)
|
||||||
from common.components.search_select import (
|
from common.components.search_select import (
|
||||||
FilterSelect,
|
FilterSelect,
|
||||||
|
LabeledOption,
|
||||||
SearchSelect,
|
SearchSelect,
|
||||||
SearchSelectOption,
|
SearchSelectOption,
|
||||||
searchselect_selected,
|
searchselect_selected,
|
||||||
@@ -87,6 +88,7 @@ __all__ = [
|
|||||||
"PopoverTruncated",
|
"PopoverTruncated",
|
||||||
"SearchField",
|
"SearchField",
|
||||||
"FilterSelect",
|
"FilterSelect",
|
||||||
|
"LabeledOption",
|
||||||
"SearchSelect",
|
"SearchSelect",
|
||||||
"SearchSelectOption",
|
"SearchSelectOption",
|
||||||
"searchselect_selected",
|
"searchselect_selected",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.utils.safestring import SafeText, mark_safe
|
|||||||
|
|
||||||
from common.components.core import Component
|
from common.components.core import Component
|
||||||
from common.components.primitives import Label, Span
|
from common.components.primitives import Label, Span
|
||||||
from common.components.search_select import FilterSelect
|
from common.components.search_select import FilterSelect, LabeledOption
|
||||||
|
|
||||||
|
|
||||||
class FilterChoice(NamedTuple):
|
class FilterChoice(NamedTuple):
|
||||||
@@ -19,11 +19,18 @@ class FilterChoice(NamedTuple):
|
|||||||
for enum fields the label is resolved from the fixed option list.
|
for enum fields the label is resolved from the fixed option list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
selected: list[tuple[str, str]]
|
selected: list[LabeledOption]
|
||||||
excluded: list[tuple[str, str]]
|
excluded: list[LabeledOption]
|
||||||
modifier: str
|
modifier: str
|
||||||
|
|
||||||
|
|
||||||
|
class RangeValues(NamedTuple):
|
||||||
|
"""A (min, max) string pair parsed from a range filter criterion."""
|
||||||
|
|
||||||
|
min: str
|
||||||
|
max: str
|
||||||
|
|
||||||
|
|
||||||
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
|
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +62,7 @@ def _filter_parse(filter_json: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _extract_labeled(items: list) -> list[tuple[str, str]]:
|
def _extract_labeled(items: list) -> list[LabeledOption]:
|
||||||
"""Convert a list of bare values or ``{id, label}`` dicts to ``(value, label)`` pairs."""
|
"""Convert a list of bare values or ``{id, label}`` dicts to ``(value, label)`` pairs."""
|
||||||
result = []
|
result = []
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -84,12 +91,12 @@ def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_range(existing: dict, key: str) -> tuple[str, str]:
|
def _parse_range(existing: dict, key: str) -> RangeValues:
|
||||||
"""Extract (value, value2) from a filter criterion, defaulting to ("", "")."""
|
"""Extract (min, max) from a range filter criterion, defaulting to ("", "")."""
|
||||||
field = existing.get(key, {})
|
field = existing.get(key, {})
|
||||||
if not isinstance(field, dict):
|
if not isinstance(field, dict):
|
||||||
return "", ""
|
return RangeValues("", "")
|
||||||
return str(field.get("value", "")), str(field.get("value2", ""))
|
return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(existing: dict, key: str) -> bool:
|
def _parse_bool(existing: dict, key: str) -> bool:
|
||||||
@@ -108,7 +115,7 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
|||||||
_FILTER_PREFETCH = 20
|
_FILTER_PREFETCH = 20
|
||||||
|
|
||||||
|
|
||||||
def _modifier_options(nullable: bool) -> list[tuple[str, str]]:
|
def _modifier_options(nullable: bool) -> list[LabeledOption]:
|
||||||
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
||||||
options = [("NOT_NULL", "(Any)")]
|
options = [("NOT_NULL", "(Any)")]
|
||||||
if nullable:
|
if nullable:
|
||||||
@@ -618,7 +625,7 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
|
|||||||
|
|
||||||
def FilterBar(
|
def FilterBar(
|
||||||
filter_json: str = "",
|
filter_json: str = "",
|
||||||
status_options: list[tuple[str, str]] | None = None,
|
status_options: list[LabeledOption] | None = None,
|
||||||
preset_list_url: str = "",
|
preset_list_url: str = "",
|
||||||
preset_save_url: str = "",
|
preset_save_url: str = "",
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
@@ -717,7 +724,7 @@ def FilterBar(
|
|||||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
def _find_label(options: list[tuple[str, str]], value: str) -> str:
|
def _find_label(options: list[LabeledOption], value: str) -> str:
|
||||||
for v, label in options:
|
for v, label in options:
|
||||||
if str(v) == str(value):
|
if str(v) == str(value):
|
||||||
return label
|
return label
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ class SearchSelectOption(TypedDict):
|
|||||||
data: dict[str, str] # becomes data-* attrs on the row / pill
|
data: dict[str, str] # becomes data-* attrs on the row / pill
|
||||||
|
|
||||||
|
|
||||||
|
# A lightweight (value, label) pair used wherever only those two fields are
|
||||||
|
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
|
||||||
|
# SearchSelectOption adds a ``data`` dict for extra row attributes.
|
||||||
|
LabeledOption = tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
# The pills and the search box share one flex-wrap row (with padding) so the
|
# The pills and the search box share one flex-wrap row (with padding) so the
|
||||||
# widget reads as a single clickable field; the pills wrapper uses `contents`
|
# widget reads as a single clickable field; the pills wrapper uses `contents`
|
||||||
# so its pills/hidden inputs flow as direct participants of that row, inline
|
# so its pills/hidden inputs flow as direct participants of that row, inline
|
||||||
@@ -394,11 +400,11 @@ def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
|||||||
def FilterSelect(
|
def FilterSelect(
|
||||||
*,
|
*,
|
||||||
field_name: str,
|
field_name: str,
|
||||||
options: list | None = None,
|
options: list[LabeledOption | SearchSelectOption] | None = None,
|
||||||
included: list | None = None,
|
included: list[LabeledOption | SearchSelectOption] | None = None,
|
||||||
excluded: list | None = None,
|
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||||
modifier: str = "",
|
modifier: str = "",
|
||||||
modifier_options: list[tuple[str, str]] | None = None,
|
modifier_options: list[LabeledOption] | None = None,
|
||||||
search_url: str = "",
|
search_url: str = "",
|
||||||
prefetch: int = 0,
|
prefetch: int = 0,
|
||||||
items_visible: int = 6,
|
items_visible: int = 6,
|
||||||
|
|||||||
Reference in New Issue
Block a user