Compare commits

..

2 Commits

Author SHA1 Message Date
lukas b62a0f689a Update allowed builders for pnpm
Django CI/CD / test (push) Successful in 49s
Django CI/CD / build-and-push (push) Failing after 1m7s
2026-06-08 08:37:10 +02:00
lukas c75133d9c4 Update uv.lock security 2026-06-08 08:37:10 +02:00
18 changed files with 1005 additions and 1163 deletions
+4 -6
View File
@@ -64,8 +64,8 @@ docs/ — Additional documentation
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs. - **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()` - **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()` - **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets) - **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips)
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js` - **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js`
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering. **Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
@@ -118,7 +118,8 @@ Only a small number of HTML templates remain (platform icon snippets and partial
- **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css` - **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- **Custom JS** in `games/static/js/`: - **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event) - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode) - `selectable_filter.js` — SelectableFilter widget interaction
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`) - `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment ### Deployment
@@ -158,13 +159,10 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
## Conventions for AI assistants ## Conventions for AI assistants
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database. - **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`. - **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped. - **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`. - **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **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.
+2 -6
View File
@@ -36,13 +36,10 @@ from common.components.primitives import (
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
Template,
YearPicker, YearPicker,
paginated_table_content, paginated_table_content,
) )
from common.components.search_select import ( from common.components.search_select import (
FilterSelect,
LabeledOption,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
searchselect_selected, searchselect_selected,
@@ -61,6 +58,7 @@ from common.components.domain import (
from common.components.filters import ( from common.components.filters import (
FilterBar, FilterBar,
PurchaseFilterBar, PurchaseFilterBar,
SelectableFilter,
SessionFilterBar, SessionFilterBar,
) )
@@ -87,8 +85,6 @@ __all__ = [
"Popover", "Popover",
"PopoverTruncated", "PopoverTruncated",
"SearchField", "SearchField",
"FilterSelect",
"LabeledOption",
"SearchSelect", "SearchSelect",
"SearchSelectOption", "SearchSelectOption",
"searchselect_selected", "searchselect_selected",
@@ -98,7 +94,6 @@ __all__ = [
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
"Template",
"YearPicker", "YearPicker",
"paginated_table_content", "paginated_table_content",
"GameLink", "GameLink",
@@ -112,5 +107,6 @@ __all__ = [
"_resolve_name_with_icon", "_resolve_name_with_icon",
"FilterBar", "FilterBar",
"PurchaseFilterBar", "PurchaseFilterBar",
"SelectableFilter",
"SessionFilterBar", "SessionFilterBar",
] ]
+290 -106
View File
@@ -1,38 +1,33 @@
"""Stash-style filter bars, built from FilterSelect widgets.""" """Stash-style filter bars and the SelectableFilter widget."""
from typing import NamedTuple from typing import NamedTuple
from django.db import models from django.db import models
from django.utils.html import escape
from django.utils.safestring import SafeText, mark_safe 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, LabeledOption
class FilterChoice(NamedTuple): class FilterChoice(NamedTuple):
"""Parsed include/exclude/modifier state of a filter field from filter JSON. """Parsed state of a SelectableFilter widget from a filter JSON blob."""
``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For selected: list[str]
model-backed fields the label is embedded in the filter JSON (Stash-style); excluded: list[str]
for enum fields the label is resolved from the fixed option list.
"""
selected: list[LabeledOption]
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"
_FILTER_INPUT_CLASS = (
"block w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-2 "
"focus:ring-brand focus:border-brand"
)
_FILTER_CHECKBOX_CLASS = ( _FILTER_CHECKBOX_CLASS = (
"rounded border-default-medium bg-neutral-secondary-medium " "rounded border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand" "text-brand focus:ring-brand"
@@ -54,28 +49,30 @@ def _filter_parse(filter_json: str) -> dict:
return {} return {}
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
return [(str(item["id"]), str(item["label"])) for item in items]
def _filter_get_choice(existing: dict, field: str) -> FilterChoice: def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
raw = existing.get(field, {}) raw = existing.get(field, {})
if not isinstance(raw, dict): if not isinstance(raw, dict):
return FilterChoice([], [], "") return FilterChoice([], [], "")
value = raw.get("value", [])
excluded = raw.get("excludes", [])
modifier = raw.get("modifier", "")
if isinstance(value, str):
value = [value]
if isinstance(excluded, str):
excluded = [excluded]
return FilterChoice( return FilterChoice(
selected=_extract_labeled(raw.get("value") or []), selected=[str(v) for v in (value or [])],
excluded=_extract_labeled(raw.get("excludes") or []), excluded=[str(v) for v in (excluded or [])],
modifier=raw.get("modifier") or "", modifier=modifier or "",
) )
def _parse_range(existing: dict, key: str) -> RangeValues: def _parse_range(existing: dict, key: str) -> tuple[str, str]:
"""Extract (min, max) from a range filter criterion, defaulting to ("", "").""" """Extract (value, value2) from a filter criterion, defaulting to ("", "")."""
field = existing.get(key, {}) field = existing.get(key, {})
if not isinstance(field, dict): if not isinstance(field, dict):
return RangeValues("", "") return "", ""
return RangeValues(str(field.get("value", "")), str(field.get("value2", ""))) return str(field.get("value", "")), str(field.get("value2", ""))
def _parse_bool(existing: dict, key: str) -> bool: def _parse_bool(existing: dict, key: str) -> bool:
@@ -86,56 +83,18 @@ def _parse_bool(existing: dict, key: str) -> bool:
return bool(field.get("value", False)) return bool(field.get("value", False))
# ── FilterSelect adapters ──────────────────────────────────────────────────── def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]:
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed """Return (value, label) pairs for a SelectableFilter from model rows.
# option set; model-backed fields fetch from a search endpoint on demand, with
# labels embedded in the filter JSON so pills render without a DB round-trip.
_FILTER_PREFETCH = 20 Uses values_list for efficiency (only fetches needed columns),
but unpacks each row into readable local variables.
def _modifier_options(nullable: bool) -> list[LabeledOption]:
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
options = [("NOT_NULL", "(Any)")]
if nullable:
options.append(("IS_NULL", "(None)"))
return options
def _enum_filter(
field_name: str, options, choice: FilterChoice, *, nullable
) -> SafeText:
"""A FilterSelect over a small, fully pre-rendered option set (enum field)."""
options_str = [(str(value), label) for value, label in options]
included = [(value, _find_label(options_str, value)) for value, _label in choice.selected]
excluded = [(value, _find_label(options_str, value)) for value, _label in choice.excluded]
return FilterSelect(
field_name=field_name,
options=options_str,
included=included,
excluded=excluded,
modifier=choice.modifier,
modifier_options=_modifier_options(nullable),
)
def _model_filter(
field_name: str, choice: FilterChoice, *, search_url, nullable
) -> SafeText:
"""A FilterSelect backed by a search endpoint.
Labels are embedded in the filter JSON (Stash-style), so pills render
directly from ``choice`` with no DB round-trip.
""" """
return FilterSelect( options: list[tuple[str, str]] = []
field_name=field_name, for object_id, object_name in model_class.objects.order_by(order_by).values_list(
included=[(value, label or value) for value, label in choice.selected], "id", order_by
excluded=[(value, label or value) for value, label in choice.excluded], ):
modifier=choice.modifier, options.append((str(object_id), object_name))
modifier_options=_modifier_options(nullable), return options
search_url=search_url,
prefetch=_FILTER_PREFETCH,
)
def _filter_mins_to_hrs(val) -> str: def _filter_mins_to_hrs(val) -> str:
@@ -166,6 +125,23 @@ def _filter_field(label: str, widget) -> SafeText:
) )
def _filter_number(label, name, value="", placeholder="") -> SafeText:
return _filter_field(
label,
Component(
tag_name="input",
attributes=[
("type", "number"),
("name", escape(name)),
("id", escape(name)),
("value", escape(value)),
("placeholder", escape(placeholder)),
("class", _FILTER_INPUT_CLASS),
],
),
)
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
return Label( return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading")], attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
@@ -371,7 +347,8 @@ def RangeSlider(
("data-target", min_input_id), ("data-target", min_input_id),
( (
"style", "style",
"left:0" + (";display:none" if point_mode else ""), "left:0"
+ (";display:none" if point_mode else ""),
), ),
], ],
), ),
@@ -587,19 +564,23 @@ 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[LabeledOption] | None = None, status_options: list[tuple[str, str]] | None = None,
platform_options: list[tuple[int, str]] | None = None,
preset_list_url: str = "", preset_list_url: str = "",
preset_save_url: str = "", preset_save_url: str = "",
) -> SafeText: ) -> SafeText:
"""Collapsible filter bar for the Game list.""" """Collapsible filter bar for the Game list."""
from games.models import Game from games.models import Game, Platform
if status_options is None: if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status] status_options = [(s.value, s.label) for s in Game.Status]
if platform_options is None:
platform_options = _get_filter_options(Platform)
existing = _filter_parse(filter_json) existing = _filter_parse(filter_json)
status_choice = _filter_get_choice(existing, "status") status_choice = _filter_get_choice(existing, "status")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
platform_options_str = [(str(pk), name) for pk, name in platform_options]
year_min, year_max = _parse_range(existing, "year_released") year_min, year_max = _parse_range(existing, "year_released")
mastered_value = _parse_bool(existing, "mastered") mastered_value = _parse_bool(existing, "mastered")
@@ -636,19 +617,23 @@ def FilterBar(
children=[ children=[
_filter_field( _filter_field(
"Status", "Status",
_enum_filter( SelectableFilter(
"status", "status",
status_options, status_options,
status_choice, status_choice.selected,
status_choice.excluded,
status_choice.modifier,
nullable=not Game._meta.get_field("status").has_default(), nullable=not Game._meta.get_field("status").has_default(),
), ),
), ),
_filter_field( _filter_field(
"Platform", "Platform",
_model_filter( SelectableFilter(
"platform", "platform",
platform_choice, platform_options_str,
search_url="/api/platforms/search", platform_choice.selected,
platform_choice.excluded,
platform_choice.modifier,
nullable=Game._meta.get_field("platform").null, nullable=Game._meta.get_field("platform").null,
), ),
), ),
@@ -686,7 +671,190 @@ 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[LabeledOption], value: str) -> str: def _selectable_filter_tag(
value: str, label: str, *, excluded: bool = False
) -> SafeText:
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
checkmark = "\u2717" if excluded else "\u2713"
css = "sf-tag sf-excluded" if excluded else "sf-tag"
return Span(
attributes=[
("class", css),
("data-value", value),
("data-type", "exclude" if excluded else "include"),
],
children=[
Span(
attributes=[("class", "sf-tag-text")],
children=[f"{checkmark} {label}"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-remove"),
("aria-label", "Remove"),
],
children=["\u00d7"],
),
],
)
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
return Span(
attributes=[
("class", "sf-modifier-tag active"),
("data-modifier", modifier),
],
children=[label],
)
def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
"""A modifier choice in the SelectableFilter dropdown list."""
return Component(
tag_name="div",
attributes=[
("class", "sf-option sf-modifier-option"),
("data-modifier", modifier),
("data-label", label),
],
children=[
Span(
attributes=[("class", "sf-option-label")],
children=[label],
),
],
)
def _selectable_filter_option(value: str, label: str) -> SafeText:
"""An option row with include (+) and exclude (\u2212) buttons."""
return Component(
tag_name="div",
attributes=[
("class", "sf-option"),
("data-value", value),
("data-label", label),
],
children=[
Span(
attributes=[("class", "sf-option-label")],
children=[label],
),
Span(
attributes=[("class", "sf-option-buttons")],
children=[
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-btn-include"),
("data-action", "include"),
("title", "Include"),
],
children=["+"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-btn-exclude"),
("data-action", "exclude"),
("title", "Exclude"),
],
children=["\u2212"],
),
],
),
],
)
def SelectableFilter(
field_name: str,
options: list[tuple[str, str]],
selected: list[str] | None = None,
excluded: list[str] | None = None,
modifier: str = "",
nullable: bool = True,
) -> "SafeText":
"""Stash-style selectable filter with search, include/exclude, modifier tags."""
selected = selected or []
excluded = excluded or []
modifier_options = [("NOT_NULL", "(Any)")]
if nullable:
modifier_options.append(("IS_NULL", "(None)"))
active_modifier_tag = ""
inactive_modifier_options: list[SafeText] = []
for modifier_value, modifier_label in modifier_options:
if modifier == modifier_value:
active_modifier_tag = _selectable_filter_modifier_tag(
modifier_value, modifier_label
)
else:
inactive_modifier_options.append(
_selectable_filter_modifier_option(modifier_value, modifier_label)
)
selected_tags: list[SafeText] = []
for value in selected:
selected_tags.append(
_selectable_filter_tag(value, _find_label(options, value), excluded=False)
)
for value in excluded:
selected_tags.append(
_selectable_filter_tag(value, _find_label(options, value), excluded=True)
)
option_rows: list[SafeText] = []
for value, label in options:
option_rows.append(_selectable_filter_option(value, label))
selected_area_children: list[SafeText] = []
if active_modifier_tag:
selected_area_children.append(active_modifier_tag)
selected_area_children.extend(selected_tags)
options_area_children: list[SafeText] = []
options_area_children.extend(inactive_modifier_options)
options_area_children.extend(option_rows)
return Component(
tag_name="div",
attributes=[
("class", "sf-container"),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
Component(
tag_name="div",
attributes=[("class", "sf-selected")],
children=selected_area_children,
),
Component(
tag_name="input",
attributes=[
("type", "text"),
("class", "sf-search"),
("placeholder", "Search\u2026"),
],
),
Component(
tag_name="div",
attributes=[("class", "sf-options")],
children=options_area_children,
),
],
)
def _find_label(options: list[tuple[str, str]], 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
@@ -697,8 +865,10 @@ def SessionFilterBar(
filter_json="", preset_list_url="", preset_save_url="" filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText: ) -> SafeText:
"""Collapsible filter bar for the Session list.""" """Collapsible filter bar for the Session list."""
from games.models import Game, Session from games.models import Device, Game, Session
game_options = _get_filter_options(Game)
device_options = _get_filter_options(Device)
existing = _filter_parse(filter_json) existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game") game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device") device_choice = _filter_get_choice(existing, "device")
@@ -728,19 +898,23 @@ def SessionFilterBar(
children=[ children=[
_filter_field( _filter_field(
"Game", "Game",
_model_filter( SelectableFilter(
"game", "game",
game_choice, game_options,
search_url="/api/games/search", game_choice.selected,
game_choice.excluded,
game_choice.modifier,
nullable=not Game._meta.get_field("name").has_default(), nullable=not Game._meta.get_field("name").has_default(),
), ),
), ),
_filter_field( _filter_field(
"Device", "Device",
_model_filter( SelectableFilter(
"device", "device",
device_choice, device_options,
search_url="/api/devices/search", device_choice.selected,
device_choice.excluded,
device_choice.modifier,
nullable=Session._meta.get_field("device").null, nullable=Session._meta.get_field("device").null,
), ),
), ),
@@ -772,10 +946,12 @@ def PurchaseFilterBar(
filter_json="", preset_list_url="", preset_save_url="" filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText: ) -> SafeText:
"""Collapsible filter bar for the Purchase list.""" """Collapsible filter bar for the Purchase list."""
from games.models import Purchase from games.models import Game, Platform, Purchase
type_options = Purchase.TYPES game_options = _get_filter_options(Game)
ownership_options = Purchase.OWNERSHIP_TYPES platform_options = _get_filter_options(Platform)
type_options = [(value, label) for value, label in Purchase.TYPES]
ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES]
existing = _filter_parse(filter_json) existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "games") game_choice = _filter_get_choice(existing, "games")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
@@ -799,37 +975,45 @@ def PurchaseFilterBar(
children=[ children=[
_filter_field( _filter_field(
"Game", "Game",
_model_filter( SelectableFilter(
"games", "games",
game_choice, game_options,
search_url="/api/games/search", game_choice.selected,
game_choice.excluded,
game_choice.modifier,
nullable=False, nullable=False,
), ),
), ),
_filter_field( _filter_field(
"Platform", "Platform",
_model_filter( SelectableFilter(
"platform", "platform",
platform_choice, platform_options,
search_url="/api/platforms/search", platform_choice.selected,
platform_choice.excluded,
platform_choice.modifier,
nullable=Purchase._meta.get_field("platform").null, nullable=Purchase._meta.get_field("platform").null,
), ),
), ),
_filter_field( _filter_field(
"Type", "Type",
_enum_filter( SelectableFilter(
"type", "type",
type_options, type_options,
type_choice, type_choice.selected,
type_choice.excluded,
type_choice.modifier,
nullable=not Purchase._meta.get_field("type").has_default(), nullable=not Purchase._meta.get_field("type").has_default(),
), ),
), ),
_filter_field( _filter_field(
"Ownership", "Ownership",
_enum_filter( SelectableFilter(
"ownership_type", "ownership_type",
ownership_options, ownership_options,
ownership_choice, ownership_choice.selected,
ownership_choice.excluded,
ownership_choice.modifier,
nullable=not Purchase._meta.get_field( nullable=not Purchase._meta.get_field(
"ownership_type" "ownership_type"
).has_default(), ).has_default(),
+2 -22
View File
@@ -369,16 +369,6 @@ def Label(
return Component(tag_name="label", attributes=attributes, children=children) return Component(tag_name="label", attributes=attributes, children=children)
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
attributes = attributes or []
children = children or []
return Component(tag_name="template", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in # Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
# input.css, written inline so styling stays encapsulated in the component). The # input.css, written inline so styling stays encapsulated in the component). The
# JS that builds pills client-side (search_select.js) MUST emit these exact class # JS that builds pills client-side (search_select.js) MUST emit these exact class
@@ -396,7 +386,6 @@ def Pill(
value: str = "", value: str = "",
removable: bool = False, removable: bool = False,
extra_class: str = "", extra_class: str = "",
label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
) -> SafeText: ) -> SafeText:
"""A small label pill, optionally removable (× button). """A small label pill, optionally removable (× button).
@@ -404,10 +393,6 @@ def Pill(
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove`` Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
are JS hooks only (no CSS attached). ``value`` (when set) becomes are JS hooks only (no CSS attached). ``value`` (when set) becomes
``data-value``; extra ``attributes`` are appended to the outer span. ``data-value``; extra ``attributes`` are appended to the outer span.
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``).
""" """
attributes = attributes or [] attributes = attributes or []
pill_class = f"{_PILL_CLASS} {extra_class}".strip() pill_class = f"{_PILL_CLASS} {extra_class}".strip()
@@ -416,12 +401,7 @@ def Pill(
pill_attrs.append(("data-value", str(value))) pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes) pill_attrs.extend(attributes)
label_child: HTMLTag = ( children: list[HTMLTag] = [label]
Span(attributes=[("data-search-select-label", "")], children=[label])
if label_slot
else label
)
children: list[HTMLTag] = [label_child]
if removable: if removable:
children.append( children.append(
Component( Component(
@@ -436,7 +416,7 @@ def Pill(
) )
) )
return Span(attributes=pill_attrs, children=children) return Component(tag_name="span", attributes=pill_attrs, children=children)
def CsrfInput(request) -> SafeText: def CsrfInput(request) -> SafeText:
+48 -389
View File
@@ -7,15 +7,6 @@ hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
This module imports only from ``common.components`` — it has no Django-forms or This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are ``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``data-*`` attributes wired up by ``games/static/js/search_select.js``. ``data-*`` attributes wired up by ``games/static/js/search_select.js``.
Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
filtering is purely client-side; with a ``search_url`` the loaded rows are a
window, so the JS filters the loaded rows instantly on each keystroke while
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
first open, ``0`` = none) seeds that window so the panel is populated before the
user types.
""" """
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
@@ -24,7 +15,7 @@ from typing import TypedDict
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute from common.components.core import Component, HTMLAttribute
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.primitives import Pill
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
@@ -33,32 +24,20 @@ 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 # removed border and border-default-medium, see later if it's needed
# needed — e.g. filter pill lists and modifier pseudo-options. The richer _CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium"
# SearchSelectOption adds a ``data`` dict for extra row attributes. # The pills and the search box share one flex-wrap row so the widget reads as a
LabeledOption = tuple[str, str] # single field; the pills wrapper uses `contents` so its pills/hidden inputs
# flow as direct participants of that row, inline with the search input.
_FIELD_CLASS = "flex flex-wrap items-center gap-1 p-2"
# 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`
# so its pills/hidden inputs flow as direct participants of that row, inline
# with the search input. The options panel is absolute, so it sits outside the
# flex flow. (border omitted intentionally — see if it's needed later.)
_CONTAINER_CLASS = (
"relative flex flex-wrap items-center gap-1 p-2 "
"rounded-base bg-neutral-secondary-medium"
)
_PILLS_CLASS = "contents" _PILLS_CLASS = "contents"
_SEARCH_CLASS = ( _SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
"focus:ring-0 focus:outline-hidden placeholder:text-body" "focus:ring-0 focus:outline-hidden placeholder:text-body"
) )
# top-full anchors the panel to the container's bottom edge: as an absolutely
# positioned child of the flex field, its static position would otherwise be
# centered by items-center and overlap the search box.
_OPTIONS_CLASS = ( _OPTIONS_CLASS = (
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto " "absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium "
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg" "rounded-base bg-neutral-secondary-medium shadow-lg"
) )
_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15" _OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden" _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
@@ -67,41 +46,6 @@ _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
# used to derive the panel's max-height from items_visible. # used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25 _ROW_HEIGHT_REM = 2.25
# ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
# rows/pills are cloned from server-rendered <template>s, so these strings live
# only here — never duplicated in search_select.js.
_FILTER_INCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-brand/15 text-heading"
)
_FILTER_EXCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-red-500/15 text-red-600 line-through decoration-red-400"
)
_FILTER_MODIFIER_PILL_CLASS = (
"inline-flex items-center px-2 py-0.5 text-sm rounded "
"bg-amber-500/15 text-amber-600 cursor-pointer"
)
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
_FILTER_OPTION_ROW_CLASS = (
"flex items-center justify-between px-2 py-1 rounded text-sm "
"hover:bg-neutral-secondary-strong cursor-pointer"
)
_FILTER_OPTION_LABEL_CLASS = "truncate text-body"
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
# text-body keeps the +/ readable on dark backgrounds; hover:border-brand-strong
# keeps the edge visible against the brand hover fill.
_FILTER_ACTION_BUTTON_CLASS = (
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
"border border-brand "
"hover:bg-brand hover:text-white hover:border-brand-strong"
)
_FILTER_MODIFIER_ROW_CLASS = (
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
)
def _normalize_option(option) -> SearchSelectOption: def _normalize_option(option) -> SearchSelectOption:
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict.""" """Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
@@ -120,80 +64,23 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
def _hidden_input(name: str, value) -> SafeText: def _hidden_input(name: str, value) -> SafeText:
return Input(type="hidden", attributes=[("name", name), ("value", str(value))]) return Component(
tag_name="input",
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
def _label_slot(text: str, *, extra_class: str = "") -> SafeText: )
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side."""
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
if extra_class:
attributes.append(("class", extra_class))
return Span(attributes=attributes, children=[text])
# A placeholder option for rendering template prototypes (JS overwrites it).
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> SafeText: def _option_row(option: SearchSelectOption) -> SafeText:
return Div( return Component(
tag_name="div",
attributes=[ attributes=[
("data-search-select-option", ""), ("data-ss-option", ""),
("data-value", str(option["value"])), ("data-value", str(option["value"])),
("data-label", option["label"]), ("data-label", option["label"]),
("class", _OPTION_ROW_CLASS), ("class", _OPTION_ROW_CLASS),
*_data_attributes(option["data"]), *_data_attributes(option["data"]),
], ],
children=[_label_slot(option["label"])], children=[option["label"]],
)
def _combobox_shell(
*,
container_attributes: list[HTMLAttribute],
pills: SafeText,
search_attributes: list[HTMLAttribute],
options_children: list[SafeText],
always_visible: bool,
items_visible: int,
templates: list[SafeText] | None = None,
) -> SafeText:
"""Assemble the shared, domain-agnostic combobox skeleton.
Every combobox built on top of this shell has the same three regions in the
same order: the ``pills`` region, the search box, and the options panel (which
always carries a trailing no-results node). Callers supply the already-built
``pills`` region, the ``search_attributes`` for the text box, the
``options_children`` (value rows plus any pinned pseudo-options), the
``container_attributes`` that carry the widget's identity and behaviour flags,
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look.
"""
search = Input(attributes=search_attributes)
no_results = Div(
attributes=[
("data-search-select-no-results", ""),
("class", _NO_RESULTS_CLASS),
],
children=["No results"],
)
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
options_panel = Div(
attributes=[
("data-search-select-options", ""),
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
("class", options_class),
],
children=[*options_children, no_results],
)
return Div(
attributes=container_attributes,
children=[pills, search, options_panel, *(templates or [])],
) )
@@ -207,21 +94,20 @@ def SearchSelect(
always_visible: bool = False, always_visible: bool = False,
items_visible: int = 5, items_visible: int = 5,
items_scroll: int = 10, items_scroll: int = 10,
prefetch: int = 0,
placeholder: str = "Search…", placeholder: str = "Search…",
id: str = "", id: str = "",
sync_url: bool = False, sync_url: bool = False,
autofocus: bool = False, autofocus: bool = False,
) -> SafeText: ) -> SafeText:
"""Render the search-select widget. See module docstring for the contract.""" """Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(option) for option in (selected or [])] selected = [_normalize_option(o) for o in (selected or [])]
options = [_normalize_option(option) for option in (options or [])] options = [_normalize_option(o) for o in (options or [])]
# ── Pills + their hidden inputs (the submitted channel) ── # ── Pills + their hidden inputs (the submitted channel) ──
# Multi-select renders a removable Pill per value; single-select renders no # Multi-select renders a removable Pill per value; single-select renders no
# pill — the committed label shows inside the search box instead, with a # pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside # lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-search-select-pills]` so the JS reads/writes values uniformly. # `[data-ss-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = [] pills_children: list[SafeText] = []
search_value = "" search_value = ""
if multi_select: if multi_select:
@@ -231,7 +117,6 @@ def SearchSelect(
option["label"], option["label"],
value=str(option["value"]), value=str(option["value"]),
removable=True, removable=True,
label_slot=True,
attributes=_data_attributes(option["data"]), attributes=_data_attributes(option["data"]),
) )
) )
@@ -241,14 +126,16 @@ def SearchSelect(
pills_children.append(_hidden_input(name, option["value"])) pills_children.append(_hidden_input(name, option["value"]))
search_value = option["label"] search_value = option["label"]
pills = Div( pills = Component(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)], tag_name="div",
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children, children=pills_children,
) )
# ── Search box (NO name — the query is never submitted) ── # ── Search box (NO name — the query is never submitted) ──
search_attrs: list[HTMLAttribute] = [ search_attrs: list[HTMLAttribute] = [
("data-search-select-search", ""), ("data-ss-search", ""),
("type", "text"),
("placeholder", placeholder), ("placeholder", placeholder),
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
@@ -257,29 +144,27 @@ def SearchSelect(
search_attrs.append(("autofocus", "")) search_attrs.append(("autofocus", ""))
if search_value: if search_value:
search_attrs.append(("value", search_value)) search_attrs.append(("value", search_value))
search = Component(tag_name="input", attributes=search_attrs)
# ── Options panel (pre-rendered only when there is no search_url) ── # ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(option) for option in options] if not search_url else [] option_rows = [_option_row(o) for o in options] if not search_url else []
no_results = Component(
tag_name="div",
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
children=["No results"],
)
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
options_panel = Component(
tag_name="div",
attributes=[
("data-ss-options", ""),
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
("class", options_class),
],
children=[*option_rows, no_results],
)
# ── Templates the JS clones: a row when results are fetched, a pill when container_attrs: list[HTMLAttribute] = [
# multi-select adds chosen items. ──
templates: list[SafeText] = []
if search_url:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_option_row(_BLANK_OPTION)],
)
)
if multi_select:
templates.append(
Template(
attributes=[("data-search-select-template", "pill")],
children=[Pill("", value="", removable=True, label_slot=True)],
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""), ("data-search-select", ""),
("data-name", name), ("data-name", name),
("data-search-url", search_url), ("data-search-url", search_url),
@@ -287,242 +172,16 @@ def SearchSelect(
("data-always-visible", "true" if always_visible else "false"), ("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)), ("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)), ("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"), ("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS), ("class", _CONTAINER_CLASS),
] ]
if id: if id:
container_attributes.append(("id", id)) container_attrs.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
search_attributes=search_attrs,
options_children=option_rows,
always_visible=always_visible,
items_visible=items_visible,
templates=templates,
)
def _filter_remove_button() -> SafeText:
return Component( return Component(
tag_name="button", tag_name="div",
attributes=[ attributes=container_attrs,
("type", "button"), children=[pills, search, options_panel],
("data-pill-remove", ""),
("class", _FILTER_PILL_REMOVE_CLASS),
("aria-label", "Remove"),
],
children=["×"],
)
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
symbol = "" if kind == "include" else ""
css = (
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
)
return Span(
attributes=[
("class", css),
("data-pill", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("data-search-select-type", kind),
*_data_attributes(option["data"]),
],
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
)
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Span(
attributes=[
("class", _FILTER_MODIFIER_PILL_CLASS),
("data-pill", ""),
("data-search-select-modifier", modifier_value),
],
children=[_label_slot(label), _filter_remove_button()],
)
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
return Component(
tag_name="button",
attributes=[
("type", "button"),
("data-search-select-action", action),
("class", _FILTER_ACTION_BUTTON_CLASS),
("title", title),
],
children=[symbol],
)
def _filter_option_row(value: str | int, label: str) -> SafeText:
"""A value row with include (+) and exclude () buttons."""
return Div(
attributes=[
("data-search-select-option", ""),
("data-value", str(value)),
("data-label", label),
("class", _FILTER_OPTION_ROW_CLASS),
],
children=[
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
Span(
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
children=[
_filter_action_button("include", "+", "Include"),
_filter_action_button("exclude", "", "Exclude"),
],
),
],
)
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel."""
return Div(
attributes=[
("data-search-select-modifier-option", modifier_value),
("data-label", label),
("class", _FILTER_MODIFIER_ROW_CLASS),
],
children=[label],
)
def FilterSelect(
*,
field_name: str,
options: list[LabeledOption | SearchSelectOption] | None = None,
included: list[LabeledOption | SearchSelectOption] | None = None,
excluded: list[LabeledOption | SearchSelectOption] | None = None,
modifier: str = "",
modifier_options: list[LabeledOption] | None = None,
search_url: str = "",
prefetch: int = 0,
items_visible: int = 6,
items_scroll: int = 10,
placeholder: str = "Search…",
id: str = "",
) -> SafeText:
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
Like ``SearchSelect`` but each value row carries +/ buttons that add an
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
rendered above the value rows. A selected modifier is mutually exclusive with
value pills. State is read from the DOM into the filter JSON by
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``.
``included``/``excluded`` are resolved options (value + label) so pills show
labels even when the value rows come from ``search_url``. ``options``
pre-renders the value rows for the complete-set (no ``search_url``) case.
"""
options = [_normalize_option(option) for option in (options or [])]
included = [_normalize_option(option) for option in (included or [])]
excluded = [_normalize_option(option) for option in (excluded or [])]
modifier_options = modifier_options or []
active_modifier_label = ""
for modifier_value, label in modifier_options:
if modifier_value == modifier:
active_modifier_label = label
break
# ── Pills: a lone modifier pill, or include/exclude value pills ──
pills_children: list[SafeText] = []
if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
else:
for option in included:
pills_children.append(_filter_value_pill(option, "include"))
for option in excluded:
pills_children.append(_filter_value_pill(option, "exclude"))
pills = Div(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attributes: list[HTMLAttribute] = [
("data-search-select-search", ""),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
]
# ── Options: pinned modifier rows, then value rows (pre-rendered only when
# there is no search_url; otherwise the JS fetches them) ──
modifier_rows = [
_filter_modifier_row(value, label) for value, label in modifier_options
]
value_rows = (
[_filter_option_row(option["value"], option["label"]) for option in options]
if not search_url
else []
)
# ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ──
templates: list[SafeText] = [
Template(
attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")],
),
Template(
attributes=[("data-search-select-template", "pill-exclude")],
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
),
]
if modifier_options:
templates.append(
Template(
attributes=[("data-search-select-template", "pill-modifier")],
children=[_filter_modifier_pill("", "")],
)
)
if search_url:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_filter_option_row("", "")],
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-search-select-mode", "filter"),
("data-name", field_name),
("data-search-url", search_url),
("data-multi", "true"),
("data-always-visible", "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "false"),
("class", _CONTAINER_CLASS),
]
if modifier:
container_attributes.append(("data-modifier", modifier))
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
search_attributes=search_attributes,
options_children=[*modifier_rows, *value_rows],
always_visible=False,
items_visible=items_visible,
templates=templates,
) )
@@ -537,4 +196,4 @@ def searchselect_selected(
""" """
if not values: if not values:
return [] return []
return [_normalize_option(option) for option in resolver(values)] return [_normalize_option(o) for o in resolver(values)]
+53 -77
View File
@@ -267,100 +267,76 @@ class BoolCriterion(_Criterion):
@dataclass @dataclass
class _SetCriterion(_Criterion): class MultiCriterion(_Criterion):
"""Shared base for set-membership criteria (``MultiCriterion`` / """Filter on a many-to-many or ForeignKey relationship by ID list."""
``ChoiceCriterion``).
``value`` is the include set and ``excludes`` the exclude set. The common value: list[int] = field(default_factory=list)
modifiers are implemented once here so the two subclasses cannot drift: excludes: list[int] = field(default_factory=list)
- ``INCLUDES`` — in ``value`` (when non-empty) AND not in ``excludes`` (when
non-empty). Empty lists contribute no constraint, so an exclude-only
criterion means "everything except ``excludes``".
- ``EQUALS`` — alias of ``INCLUDES``.
- ``IS_NULL`` / ``NOT_NULL`` — presence; the lists are ignored.
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by
overriding ``_extra_q``.
"""
value: list = field(default_factory=list)
excludes: list = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q: def to_q(self, field_name: str) -> Q:
modifier = self.modifier m = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS): if m == Modifier.INCLUDES:
q = Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.INCLUDES_ALL:
q = Q()
for v in self.value:
q &= Q(**{field_name: v})
return q
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for multi field")
@dataclass
class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by SelectableFilter widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q() q = Q()
if self.value: if self.value:
q &= Q(**{f"{field_name}__in": self.value}) q &= Q(**{f"{field_name}__in": self.value})
if self.excludes: if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes}) q &= ~Q(**{f"{field_name}__in": self.excludes})
return q return q
if modifier == Modifier.IS_NULL: if m == Modifier.EXCLUDES:
return Q(**{f"{field_name}__isnull": True})
if modifier == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
extra = self._extra_q(field_name)
if extra is not None:
return extra
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
def _extra_q(self, field_name: str) -> Q | None:
"""Hook for subclass-specific modifiers; ``None`` means unsupported."""
return None
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
result = super().from_json(data)
if result is None:
return None
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
# so the querying layer stays clean and typed.
result.value = [item["id"] if isinstance(item, dict) else item for item in result.value]
result.excludes = [item["id"] if isinstance(item, dict) else item for item in result.excludes]
return result
@dataclass
class MultiCriterion(_SetCriterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
def _extra_q(self, field_name: str) -> Q | None:
if self.modifier == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if self.modifier == Modifier.INCLUDES_ALL:
q = Q()
for value in self.value:
q &= Q(**{field_name: value})
return q
return None
@dataclass
class ChoiceCriterion(_SetCriterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by FilterSelect widgets for status, ownership_type, etc.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
def _extra_q(self, field_name: str) -> Q | None:
if self.modifier == Modifier.EXCLUDES:
q = Q() q = Q()
if self.value: if self.value:
q &= ~Q(**{f"{field_name}__in": self.value}) q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes: if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes}) q &= Q(**{f"{field_name}__in": self.excludes})
return q return q
if self.modifier == Modifier.NOT_EQUALS: if m == Modifier.EQUALS:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value}) return ~Q(**{f"{field_name}__in": self.value})
return None if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ──────────────────────────────────────────────────── # ── OperatorFilter base ────────────────────────────────────────────────────
+45
View File
@@ -232,3 +232,48 @@ textarea:disabled {
} }
} }
/* SelectableFilter widget styling */
.sf-container {
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
}
.sf-selected {
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
}
.sf-tag {
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
}
.sf-tag.sf-excluded {
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
}
.sf-remove {
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
}
.sf-modifier-tag {
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
}
.sf-search {
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
&:focus {
@apply ring-0 outline-hidden;
}
}
.sf-options {
@apply max-h-40 overflow-y-auto p-1 text-body;
}
.sf-option {
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
}
.sf-option-label {
@apply truncate;
}
.sf-option-buttons {
@apply flex gap-1 ml-2 shrink-0;
}
.sf-btn-include,
.sf-btn-exclude {
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
}
.sf-modifier-option {
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
}
+165 -48
View File
@@ -811,9 +811,6 @@
.static { .static {
position: static; position: static;
} }
.sticky {
position: sticky;
}
.inset-0 { .inset-0 {
inset: calc(var(--spacing) * 0); inset: calc(var(--spacing) * 0);
} }
@@ -1288,9 +1285,6 @@
.ml-1 { .ml-1 {
margin-left: calc(var(--spacing) * 1); margin-left: calc(var(--spacing) * 1);
} }
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
@@ -2080,12 +2074,6 @@
.bg-amber-50 { .bg-amber-50 {
background-color: var(--color-amber-50); background-color: var(--color-amber-50);
} }
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black\/70 { .bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent); background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2191,12 +2179,6 @@
.bg-red-500 { .bg-red-500 {
background-color: var(--color-red-500); background-color: var(--color-red-500);
} }
.bg-red-500\/15 {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
}
.bg-red-600 { .bg-red-600 {
background-color: var(--color-red-600); background-color: var(--color-red-600);
} }
@@ -2580,9 +2562,6 @@
.text-amber-500 { .text-amber-500 {
color: var(--color-amber-500); color: var(--color-amber-500);
} }
.text-amber-600 {
color: var(--color-amber-600);
}
.text-amber-800 { .text-amber-800 {
color: var(--color-amber-800); color: var(--color-amber-800);
} }
@@ -2679,18 +2658,12 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.line-through {
text-decoration-line: line-through;
}
.no-underline\! { .no-underline\! {
text-decoration-line: none !important; text-decoration-line: none !important;
} }
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }
.decoration-red-400 {
text-decoration-color: var(--color-red-400);
}
.decoration-slate-500 { .decoration-slate-500 {
text-decoration-color: var(--color-slate-500); text-decoration-color: var(--color-slate-500);
} }
@@ -2940,13 +2913,6 @@
} }
} }
} }
.hover\:border-brand-strong {
&:hover {
@media (hover: hover) {
border-color: var(--color-brand-strong);
}
}
}
.hover\:border-default { .hover\:border-default {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -2968,13 +2934,6 @@
} }
} }
} }
.hover\:bg-brand {
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
}
.hover\:bg-brand-strong { .hover\:bg-brand-strong {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3034,13 +2993,6 @@
} }
} }
} }
.hover\:bg-neutral-secondary-strong {
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.hover\:bg-neutral-tertiary-medium { .hover\:bg-neutral-tertiary-medium {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -4429,6 +4381,171 @@ form input:disabled, select:disabled, textarea:disabled {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
} }
.sf-container {
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
}
.sf-selected {
display: flex;
min-height: 2rem;
flex-wrap: wrap;
gap: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 2);
}
.sf-tag {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1);
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
}
.sf-tag.sf-excluded {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
color: var(--color-red-600);
text-decoration-line: line-through;
text-decoration-color: var(--color-red-400);
}
.sf-remove {
margin-left: calc(var(--spacing) * 1);
cursor: pointer;
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-body);
&:hover {
@media (hover: hover) {
color: var(--color-heading);
}
}
}
.sf-modifier-tag {
display: inline-flex;
cursor: pointer;
align-items: center;
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-amber-600);
}
.sf-search {
display: block;
width: 100%;
border-style: var(--tw-border-style);
border-width: 0px;
border-top-style: var(--tw-border-style);
border-top-width: 1px;
border-color: var(--color-default-medium);
background-color: transparent;
padding: calc(var(--spacing) * 2);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
.sf-options {
max-height: calc(var(--spacing) * 40);
overflow-y: auto;
padding: calc(var(--spacing) * 1);
color: var(--color-body);
}
.sf-option {
display: flex;
cursor: pointer;
align-items: center;
justify-content: space-between;
border-radius: var(--radius);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.sf-option-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-option-buttons {
margin-left: calc(var(--spacing) * 2);
display: flex;
flex-shrink: 0;
gap: calc(var(--spacing) * 1);
}
.sf-btn-include, .sf-btn-exclude {
display: flex;
height: calc(var(--spacing) * 5);
width: calc(var(--spacing) * 5);
align-items: center;
justify-content: center;
border-radius: var(--radius);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
&:hover {
@media (hover: hover) {
border-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-white);
}
}
}
.sf-modifier-option {
cursor: pointer;
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-body);
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
@layer base { @layer base {
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select { input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
appearance: none; appearance: none;
+50 -19
View File
@@ -59,31 +59,62 @@
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" }; filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
} }
// ── FilterSelect widgets (data-search-select-mode="filter") ── // ── Generic SelectableFilter widgets ──
// readSearchSelect serialises each into data-included/data-excluded/data-modifier. readSelectableFilters(form);
readSearchSelect(form); var widgets = form.querySelectorAll("[data-selectable-filter]");
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]'); widgets.forEach(function (w) {
widgets.forEach(function (widget) { var field = w.getAttribute("data-selectable-filter");
var field = widget.getAttribute("data-name"); var inc = parseJSONAttr(w, "data-included");
var included = parseJSONAttr(widget, "data-included"); var exc = parseJSONAttr(w, "data-excluded");
var excluded = parseJSONAttr(widget, "data-excluded"); var mod = w.getAttribute("data-modifier");
var modifier = widget.getAttribute("data-modifier"); if (mod === "NOT_NULL" || mod === "IS_NULL") {
if (modifier === "NOT_NULL" || modifier === "IS_NULL") { filter[field] = { modifier: mod };
filter[field] = { modifier: modifier }; } else if (inc.length > 0 || exc.length > 0) {
} else if (included.length > 0 || excluded.length > 0) { var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
// All filter pills carry {id, label}; store them as-is so the filter
// URL and saved presets are self-describing (Stash-style).
filter[field] = { filter[field] = {
value: included.map(function (item) { return {id: item.id, label: item.label}; }), value: isIdField ? inc.map(Number) : inc,
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }), excludes: isIdField ? exc.map(Number) : exc,
modifier: modifier || "INCLUDES", modifier: mod || "INCLUDES",
}; };
} }
}); });
// ── Session-specific fields ── // ── Session-specific fields ──
var pageIsSessions = var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// Emulated checkbox (sessions page) // Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]'); var emulated = form.querySelector('[name="filter-emulated"]');
+99 -245
View File
@@ -6,53 +6,49 @@
* focus clears it to search, picking an option fills it), with a lone hidden * focus clears it to search, picking an option fills it), with a lone hidden
* <input> carrying the value. Both keep hidden inputs so Django validation works. * <input> carrying the value. Both keep hidden inputs so Django validation works.
* *
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows * Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
* carry +/ buttons that add include (✓) / exclude (✗) pills, plus pinned * each widget guarded with el._ssInit.
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar.
* *
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with * The pill / option class strings below are kept byte-identical to the Python
* element._searchSelectInit. * Pill / SearchSelect components so Tailwind generates the classes and
* * server-rendered and JS-created pills are indistinguishable.
* Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect /
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
* and data-* attributes — so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here.
*/ */
(function () { (function () {
"use strict"; "use strict";
var PILL_CLASS =
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
"bg-brand/15 text-heading";
var PILL_REMOVE_CLASS =
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
var OPTION_ROW_CLASS =
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
var DEBOUNCE_MS = 500; var DEBOUNCE_MS = 500;
function initAll() { function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (element) { document.querySelectorAll("[data-search-select]").forEach(function (el) {
if (element._searchSelectInit) return; if (el._ssInit) return;
element._searchSelectInit = true; el._ssInit = true;
initWidget(element); initWidget(el);
}); });
} }
function initWidget(container) { function initWidget(container) {
var search = container.querySelector("[data-search-select-search]"); var search = container.querySelector("[data-ss-search]");
var options = container.querySelector("[data-search-select-options]"); var options = container.querySelector("[data-ss-options]");
var pills = container.querySelector("[data-search-select-pills]"); var pills = container.querySelector("[data-ss-pills]");
if (!search || !options || !pills) return; if (!search || !options || !pills) return;
var name = container.getAttribute("data-name"); var name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url"); var searchUrl = container.getAttribute("data-search-url");
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
var multi = container.getAttribute("data-multi") === "true"; var multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true"; var alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10; var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
var syncUrl = container.getAttribute("data-sync-url") === "true"; var syncUrl = container.getAttribute("data-sync-url") === "true";
var noResults = options.querySelector("[data-search-select-no-results]"); var noResults = options.querySelector("[data-ss-no-results]");
var debounceTimer = null; var debounceTimer = null;
var pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false;
function showPanel() { function showPanel() {
options.classList.remove("hidden"); options.classList.remove("hidden");
@@ -67,229 +63,115 @@
// ── Render server-fetched rows into the panel ── // ── Render server-fetched rows into the panel ──
function renderRows(items) { function renderRows(items) {
options.querySelectorAll("[data-search-select-option]").forEach(function (row) { options.querySelectorAll("[data-ss-option]").forEach(function (r) {
row.remove(); r.remove();
}); });
items.slice(0, itemsScroll).forEach(function (item) { items.slice(0, itemsScroll).forEach(function (item) {
options.insertBefore(buildRow(item), noResults || null); options.insertBefore(buildRow(item), noResults || null);
}); });
setNoResults(items.length === 0);
showPanel(); showPanel();
} }
// ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
function cloneTemplate(name) {
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
return template
? template.content.firstElementChild.cloneNode(true)
: null;
}
function setLabel(node, label) {
var slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label;
}
function applyData(node, data) {
data = data || {};
Object.keys(data).forEach(function (key) {
node.setAttribute("data-" + key, data[key]);
});
}
// Build an option row by cloning the "row" template (the same prototype the
// server renders, so fetched and pre-rendered rows are identical).
function buildRow(option) { function buildRow(option) {
var row = cloneTemplate("row"); var row = document.createElement("div");
if (!row) return document.createComment("ss-row"); row.setAttribute("data-ss-option", "");
row.setAttribute("data-value", option.value); row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label); row.setAttribute("data-label", option.label);
applyData(row, option.data); row.className = OPTION_ROW_CLASS;
setLabel(row, option.label); var data = option.data || {};
row._searchSelectOption = option; Object.keys(data).forEach(function (key) {
row.setAttribute("data-" + key, data[key]);
});
row.textContent = option.label;
row._ssOption = option;
return row; return row;
} }
// ── Client-side filter of the currently loaded rows. Returns the number of // ── Client-side filter of pre-rendered rows ──
// visible rows so the caller decides whether to show the no-results node. ── function filterRows(q) {
function filterRows(query) { var lower = q.toLowerCase();
var lower = query.toLowerCase(); var anyVisible = false;
var visibleCount = 0; options.querySelectorAll("[data-ss-option]").forEach(function (item) {
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase(); var label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1; var match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none"; item.style.display = match ? "" : "none";
if (match) visibleCount += 1; if (match) anyVisible = true;
}); });
return visibleCount; setNoResults(!anyVisible);
}
// ── Fetch matching rows from the server. The previous in-flight request is
// aborted so a slower earlier response can never overwrite a newer one. ──
function fetchFromServer(query) {
if (pendingRequest) pendingRequest.abort();
pendingRequest = new AbortController();
var url = searchUrl + "?q=" + encodeURIComponent(query);
if (prefetch && !query) url += "&limit=" + prefetch;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(function (response) {
return response.json();
})
.then(function (items) {
pendingRequest = null;
renderRows(items);
// Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0);
})
.catch(function (error) {
if (error && error.name === "AbortError") return; // superseded
pendingRequest = null;
setNoResults(true);
});
}
// Called on every keystroke. With a search_url, filter the loaded window
// instantly (zero latency) and debounce a server request for the rest;
// no-results stays hidden until the response decides it, to avoid a flash
// over an incomplete window. Without a search_url the loaded set is complete,
// so the client-side filter is authoritative.
function runSearch() {
var query = search.value.trim();
showPanel(); showPanel();
if (searchUrl) { }
filterRows(query);
setNoResults(false); function runSearch() {
var q = search.value.trim();
if (searchUrl && q) {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { debounceTimer = setTimeout(function () {
fetchFromServer(query); fetch(searchUrl + "?q=" + encodeURIComponent(q), {
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(renderRows)
.catch(function () {
setNoResults(true);
});
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
} else { } else {
setNoResults(filterRows(query) === 0); filterRows(q);
} }
} }
// ── Single-select combobox: the search box shows the committed label; // ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ── // focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._searchSelectLabel = search.value; if (!multi) container._ssLabel = search.value;
search.addEventListener("focus", function () { search.addEventListener("focus", function () {
if (!multi) { if (!multi) {
// Hide the committed label so the box becomes a fresh search field. // Hide the committed label so the box becomes a fresh search field.
search.value = ""; search.value = "";
container._searchSelectDirty = false; container._ssDirty = false;
}
showPanel();
if (searchUrl) {
if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced).
hasPrefetched = true;
fetchFromServer("");
} else {
// Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim());
setNoResults(false);
}
} else {
setNoResults(filterRows(search.value.trim()) === 0);
} }
runSearch();
}); });
search.addEventListener("input", function () { search.addEventListener("input", function () {
if (!multi) container._searchSelectDirty = true; if (!multi) container._ssDirty = true;
runSearch(); runSearch();
}); });
if (!multi) { if (!multi) {
search.addEventListener("blur", function () { search.addEventListener("blur", function () {
// Defer so an option click (which fires before blur settles) wins. // Defer so an option click (which fires before blur settles) wins.
setTimeout(function () { setTimeout(function () {
if (container._searchSelectDirty && search.value.trim() === "") { if (container._ssDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect. // User intentionally cleared the box → deselect.
pills.innerHTML = ""; pills.innerHTML = "";
container._searchSelectLabel = ""; container._ssLabel = "";
emitChange(null); emitChange(null);
} else { } else {
// Focused-and-left, or typed a partial query without picking → // Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection). // restore the committed label (no-op right after a selection).
search.value = container._searchSelectLabel || ""; search.value = container._ssLabel || "";
} }
}, 120); }, 120);
}); });
} }
// Clicking an option must not blur the input before the click selects. // Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", function (event) { options.addEventListener("mousedown", function (e) {
event.preventDefault(); e.preventDefault();
}); });
// ── Option click → select (form mode) or include/exclude (filter mode) ── // ── Option click → select ──
options.addEventListener("click", function (event) { options.addEventListener("click", function (e) {
if (isFilter) { var row = e.target.closest("[data-ss-option]");
handleFilterOptionClick(event);
return;
}
var row = event.target.closest("[data-search-select-option]");
if (!row) return; if (!row) return;
selectOption(optionFromRow(row)); var option = optionFromRow(row);
selectOption(option);
}); });
function handleFilterOptionClick(event) {
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
var modifierRow = event.target.closest("[data-search-select-modifier-option]");
if (modifierRow) {
setModifier(
modifierRow.getAttribute("data-search-select-modifier-option"),
modifierRow.getAttribute("data-label")
);
return;
}
// Include / exclude button on a value row.
var button = event.target.closest("[data-search-select-action]");
if (!button) return;
var row = button.closest("[data-search-select-option]");
if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
}
// Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears an active modifier — the two are mutually exclusive.
function addFilterPill(option, kind) {
clearModifier();
var existing = pills.querySelector(
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
);
if (existing) existing.remove();
pills.appendChild(buildFilterValuePill(option, kind));
emitChange(null);
}
function buildFilterValuePill(option, kind) {
var pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label);
applyData(pill, option.data);
setLabel(pill, option.label);
return pill;
}
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
function setModifier(modifierValue, label) {
pills.innerHTML = "";
var pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.appendChild(pill);
container.setAttribute("data-modifier", modifierValue);
hidePanel();
emitChange(null);
}
function clearModifier() {
var modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
}
function optionFromRow(row) { function optionFromRow(row) {
if (row._searchSelectOption) return row._searchSelectOption; if (row._ssOption) return row._ssOption;
var data = {}; var data = {};
Object.keys(row.dataset).forEach(function (key) { Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") { if (key !== "value" && key !== "label" && key !== "ssOption") {
@@ -310,29 +192,39 @@
} }
} else { } else {
// Single-select: no pill — show the label in the search box and keep a // Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-search-select-pills] for submission. // lone hidden input under [data-ss-pills] for submission.
pills.innerHTML = ""; pills.innerHTML = "";
pills.appendChild(buildHidden(option.value)); pills.appendChild(buildHidden(option.value));
search.value = option.label; search.value = option.label;
container._searchSelectLabel = option.label; container._ssLabel = option.label;
container._searchSelectDirty = false; container._ssDirty = false;
hidePanel(); hidePanel();
} }
emitChange(option); emitChange(option);
} }
function addPill(option) { function addPill(option) {
var pill = buildPill(option); pills.appendChild(buildPill(option));
if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value)); pills.appendChild(buildHidden(option.value));
} }
function buildPill(option) { function buildPill(option) {
var pill = cloneTemplate("pill"); var pill = document.createElement("span");
if (!pill) return null; pill.className = PILL_CLASS;
pill.setAttribute("data-pill", "");
pill.setAttribute("data-value", option.value); pill.setAttribute("data-value", option.value);
applyData(pill, option.data); var data = option.data || {};
setLabel(pill, option.label); Object.keys(data).forEach(function (key) {
pill.setAttribute("data-" + key, data[key]);
});
pill.appendChild(document.createTextNode(option.label));
var remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("data-pill-remove", "");
remove.className = PILL_REMOVE_CLASS;
remove.setAttribute("aria-label", "Remove");
remove.textContent = "×";
pill.appendChild(remove);
return pill; return pill;
} }
@@ -345,21 +237,11 @@
} }
// ── Pill × → remove ── // ── Pill × → remove ──
pills.addEventListener("click", function (event) { pills.addEventListener("click", function (e) {
var removeButton = event.target.closest("[data-pill-remove]"); var removeBtn = e.target.closest("[data-pill-remove]");
if (!removeButton) return; if (!removeBtn) return;
var pill = removeButton.closest("[data-pill]"); var pill = removeBtn.closest("[data-pill]");
if (!pill) return; if (!pill) return;
if (isFilter) {
// Filter pills have no hidden input; a modifier pill also clears the
// container flag.
if (pill.hasAttribute("data-search-select-modifier")) {
container.removeAttribute("data-modifier");
}
pill.remove();
emitChange(null);
return;
}
var value = pill.getAttribute("data-value"); var value = pill.getAttribute("data-value");
pill.remove(); pill.remove();
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]'); var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
@@ -406,8 +288,8 @@
} }
// ── Close panel on outside click ── // ── Close panel on outside click ──
document.addEventListener("click", function (event) { document.addEventListener("click", function (e) {
if (!container.contains(event.target)) hidePanel(); if (!container.contains(e.target)) hidePanel();
}); });
} }
@@ -416,39 +298,11 @@
return String(value).replace(/["\\]/g, "\\$&"); return String(value).replace(/["\\]/g, "\\$&");
} }
// Serialise each widget's current state onto data-* attributes for the caller. // Forward-looking hook (parallels readSelectableFilters): write each widget's
// Form widgets expose data-values (the submitted hidden-input values); filter // current values to a data-values JSON attribute.
// widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read.
window.readSearchSelect = function (form) { window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) { form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-search-select-pills]"); var pills = container.querySelector("[data-ss-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
var included = [];
var excluded = [];
var modifier = "";
if (pills) {
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
var pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier;
return;
}
var value = pill.getAttribute("data-value");
var label = pill.getAttribute("data-label") || "";
if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push({id: value, label: label});
} else {
included.push({id: value, label: label});
}
});
}
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
else container.removeAttribute("data-modifier");
return;
}
var values = pills var values = pills
? Array.prototype.map.call( ? Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'), pills.querySelectorAll('input[type="hidden"]'),
+149
View File
@@ -0,0 +1,149 @@
/**
* SelectableFilter widget — Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
+1 -1
View File
@@ -149,7 +149,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
content, content,
title="Manage games", title="Manage games",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js") + ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+1 -1
View File
@@ -142,7 +142,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
content, content,
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js") + ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+1 -1
View File
@@ -182,7 +182,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
content, content,
title="Manage sessions", title="Manage sessions",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js") + ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+23 -4
View File
@@ -15,6 +15,7 @@ from django.test import TestCase
from common.components import ( from common.components import (
FilterBar, FilterBar,
PurchaseFilterBar, PurchaseFilterBar,
SelectableFilter,
SessionFilterBar, SessionFilterBar,
) )
from games.models import Device, Game, Platform from games.models import Device, Game, Platform
@@ -93,15 +94,14 @@ class FilterBarRenderingTest(TestCase):
self._assert_range_slider(html) self._assert_range_slider(html)
def test_game_filter_bar_roundtrips_selected_status(self): def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as an include pill in the widget.""" """A status in filter_json renders as a selected tag in the widget."""
filter_json = json.dumps({"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}}) filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}})
html = str( html = str(
FilterBar( FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s" filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
) )
) )
self.assertIn('data-search-select-mode="filter"', html) self.assertIn("sf-tag", html)
self.assertIn('data-search-select-type="include"', html) # rendered as an include pill
self.assertIn('data-value="f"', html) # selected status reflected in widget self.assertIn('data-value="f"', html) # selected status reflected in widget
self.assertIn("Finished", html) # ...with its label self.assertIn("Finished", html) # ...with its label
self.assertNoEscapedTags(html) self.assertNoEscapedTags(html)
@@ -110,3 +110,22 @@ class FilterBarRenderingTest(TestCase):
# for the double-escape bug the dedup fixed. # for the double-escape bug the dedup fixed.
self.assertIn("&quot;status&quot;", html) self.assertIn("&quot;status&quot;", html)
self.assertNotIn("&amp;quot;", html) self.assertNotIn("&amp;quot;", html)
class SelectableFilterTest(TestCase):
"""The shared widget the deduped FilterBar will be built on."""
OPTIONS = [("f", "Finished"), ("a", "Abandoned"), ("u", "Unplayed")]
def test_plain_widget_has_no_tags(self):
html = str(SelectableFilter("status", self.OPTIONS))
self.assertNotIn("sf-tag", html)
def test_include_and_exclude_tags(self):
html = str(
SelectableFilter("status", self.OPTIONS, selected=["f"], excluded=["a"])
)
self.assertIn('data-type="include"', html)
self.assertIn('data-type="exclude"', html)
self.assertIn("Finished", html)
self.assertIn("Abandoned", html)
+18 -50
View File
@@ -10,7 +10,6 @@ from common.criteria import (
ChoiceCriterion, ChoiceCriterion,
IntCriterion, IntCriterion,
Modifier, Modifier,
MultiCriterion,
StringCriterion, StringCriterion,
) )
from common.components import FilterBar from common.components import FilterBar
@@ -99,38 +98,6 @@ class TestChoiceCriterion:
assert c.to_q("status") == ~Q(status__in=["f"]) assert c.to_q("status") == ~Q(status__in=["f"])
class TestMultiCriterion:
def test_includes(self):
c = MultiCriterion(value=[797], modifier=Modifier.INCLUDES)
assert c.to_q("game_id") == Q(game_id__in=[797])
def test_excludes_only_empty_value(self):
"""Exclude one device with no includes — value=[], excludes=[11].
Regression: an empty ``value`` must not add ``__in=[]`` (which matches
nothing); the criterion should mean "all rows except device 11".
"""
c = MultiCriterion(value=[], excludes=[11], modifier=Modifier.INCLUDES)
assert c.to_q("device_id") == ~Q(device_id__in=[11])
def test_include_and_exclude(self):
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.INCLUDES)
assert c.to_q("game_id") == Q(game_id__in=[1]) & ~Q(game_id__in=[2])
def test_is_null(self):
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)
assert c.to_q("device_id") == Q(device_id__isnull=True)
def test_from_json_strips_embedded_labels(self):
"""from_json normalises {id, label} dicts to bare ids."""
c = MultiCriterion.from_json(
{"value": [{"id": 797, "label": "Hollow Knight"}], "excludes": [{"id": 11, "label": "Steam Deck"}]}
)
assert c.value == [797]
assert c.excludes == [11]
assert c.to_q("game_id") == Q(game_id__in=[797]) & ~Q(game_id__in=[11])
class TestChoiceCriterionAgainstDB: class TestChoiceCriterionAgainstDB:
"""Verify ChoiceCriterion produces correct DB results.""" """Verify ChoiceCriterion produces correct DB results."""
@@ -268,20 +235,20 @@ class TestGameFilterToQ:
class TestFilterBarRendering: class TestFilterBarRendering:
"""Tests for FilterBar with FilterSelect widgets.""" """Tests for FilterBar with SelectableFilter widgets."""
def test_status_uses_filter_select(self): def test_status_uses_selectable_filter(self):
html = str(FilterBar()) html = str(FilterBar(platform_options=[]))
assert 'data-search-select-mode="filter"' in html assert "data-selectable-filter" in html
assert 'data-name="status"' in html
def test_mastered_not_checked_by_default(self): def test_mastered_not_checked_by_default(self):
html = str(FilterBar(filter_json="")) html = str(FilterBar(filter_json="", platform_options=[]))
assert 'checked="true"' not in html assert 'checked="true"' not in html
def test_mastered_checked_when_filtered(self): def test_mastered_checked_when_filtered(self):
html = str( html = str(
FilterBar( FilterBar(
platform_options=[],
filter_json=json.dumps( filter_json=json.dumps(
{"mastered": {"value": True, "modifier": "EQUALS"}} {"mastered": {"value": True, "modifier": "EQUALS"}}
), ),
@@ -292,8 +259,9 @@ class TestFilterBarRendering:
def test_status_prefilled(self): def test_status_prefilled(self):
html = str( html = str(
FilterBar( FilterBar(
platform_options=[],
filter_json=json.dumps( filter_json=json.dumps(
{"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}} {"status": {"value": ["f"], "modifier": "INCLUDES"}}
), ),
) )
) )
@@ -301,19 +269,19 @@ class TestFilterBarRendering:
assert "Finished" in html assert "Finished" in html
def test_no_hx_get(self): def test_no_hx_get(self):
html = str(FilterBar()) html = str(FilterBar(platform_options=[]))
assert "hx-get" not in html assert "hx-get" not in html
def test_platform_uses_search_url(self): def test_platform_options_rendered(self):
"""Platform is model-backed: rows are fetched, not pre-rendered.""" html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")]))
html = str(FilterBar()) assert "Steam" in html
assert 'data-search-url="/api/platforms/search"' in html assert "Switch" in html
def test_status_has_no_modifiers(self): def test_status_has_no_modifiers(self):
"""Non-nullable fields should not show (None) but MUST show (Any).""" """Non-nullable fields should not show (None) but MUST show (Any)."""
html = str(FilterBar()) html = str(FilterBar(platform_options=[]))
status_start = html.find('data-name="status"') status_start = html.find('data-selectable-filter="status"')
platform_start = html.find('data-name="platform"') platform_start = html.find('data-selectable-filter="platform"')
status_section = html[status_start:platform_start] status_section = html[status_start:platform_start]
# Must have (Any) — always available # Must have (Any) — always available
assert "(Any)" in status_section assert "(Any)" in status_section
@@ -322,8 +290,8 @@ class TestFilterBarRendering:
def test_platform_has_modifiers(self): def test_platform_has_modifiers(self):
"""Nullable ForeignKey fields should show (Any)/(None).""" """Nullable ForeignKey fields should show (Any)/(None)."""
html = str(FilterBar()) html = str(FilterBar(platform_options=[(1, "Steam")]))
platform_start = html.find('data-name="platform"') platform_start = html.find('data-selectable-filter="platform"')
platform_section = html[platform_start:] platform_section = html[platform_start:]
# Should have at least one modifier option # Should have at least one modifier option
assert "(Any)" in platform_section or "(None)" in platform_section assert "(Any)" in platform_section or "(None)" in platform_section
+6 -121
View File
@@ -7,7 +7,6 @@ import django.test
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components import ( from common.components import (
FilterSelect,
Pill, Pill,
SearchSelect, SearchSelect,
searchselect_selected, searchselect_selected,
@@ -52,7 +51,7 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_empty_options_renders_no_results_scaffold(self): def test_empty_options_renders_no_results_scaffold(self):
html = SearchSelect(name="games") html = SearchSelect(name="games")
self.assertIn("data-search-select-no-results", html) self.assertIn("data-ss-no-results", html)
self.assertIn("No results", html) self.assertIn("No results", html)
def test_outer_container_carries_config(self): def test_outer_container_carries_config(self):
@@ -71,7 +70,7 @@ class SearchSelectComponentTest(unittest.TestCase):
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
) )
self.assertIn("data-pill", html) self.assertIn("data-pill", html)
self.assertIn('<input name="games" value="7" type="hidden">', html) self.assertIn('<input type="hidden" name="games" value="7">', html)
self.assertIn('data-platform="2"', html) self.assertIn('data-platform="2"', html)
# exactly one submitted value (the hidden input) — the search box has no # exactly one submitted value (the hidden input) — the search box has no
# name. The leading space avoids matching the container's data-name. # name. The leading space avoids matching the container's data-name.
@@ -86,18 +85,18 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertNotIn("data-pill", html) self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html) self.assertIn('value="Game A"', html)
# the value is still submitted via a lone hidden input # the value is still submitted via a lone hidden input
self.assertIn('<input name="games" value="7" type="hidden">', html) self.assertIn('<input type="hidden" name="games" value="7">', html)
self.assertEqual(html.count(' name="games"'), 1) self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self): def test_search_box_has_no_name(self):
html = SearchSelect(name="games") html = SearchSelect(name="games")
self.assertIn("data-search-select-search", html) self.assertIn("data-ss-search", html)
# container exposes data-name, never a submittable name on the search box # container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0) self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self): def test_tuple_options_are_normalized(self):
html = SearchSelect(name="t", options=[("1", "One")]) html = SearchSelect(name="t", options=[("1", "One")])
self.assertIn('data-search-select-option=""', html) self.assertIn('data-ss-option=""', html)
self.assertIn('data-value="1"', html) self.assertIn('data-value="1"', html)
self.assertIn("One", html) self.assertIn("One", html)
@@ -105,121 +104,7 @@ class SearchSelectComponentTest(unittest.TestCase):
html = SearchSelect( html = SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search" name="t", options=[("1", "One")], search_url="/api/games/search"
) )
# No pre-rendered rows in the live panel; the row prototype lives only in self.assertNotIn('data-ss-option=""', html)
# the cloneable <template>.
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
def test_templates_carry_label_slot_for_js_cloning(self):
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
# only fills text — classes/structure stay server-side.
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-template="pill"', html)
self.assertIn("data-search-select-label", html)
def test_shell_region_order_pills_search_options(self):
# The shared shell assembles the three regions in a fixed order; option
# rows precede the trailing no-results node inside the options panel.
html = SearchSelect(name="t", options=[("1", "One")])
pills = html.index("data-search-select-pills")
search = html.index("data-search-select-search")
options = html.index("data-search-select-options")
option_row = html.index('data-search-select-option=""')
no_results = html.index("data-search-select-no-results")
self.assertLess(pills, search)
self.assertLess(search, options)
self.assertLess(options, option_row)
self.assertLess(option_row, no_results)
class FilterSelectComponentTest(unittest.TestCase):
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
def test_returns_safetext(self):
self.assertIsInstance(FilterSelect(field_name="type"), SafeText)
def test_is_filter_mode_on_shared_shell(self):
html = FilterSelect(field_name="type")
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
self.assertIn("data-search-select", html)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('data-name="type"', html)
# No name is submitted — state is read from the DOM into the filter JSON.
self.assertEqual(html.count(' name="type"'), 0)
def test_value_rows_have_include_exclude_buttons(self):
html = FilterSelect(field_name="type", options=[("g", "Game")])
self.assertIn('data-search-select-action="include"', html)
self.assertIn('data-search-select-action="exclude"', html)
self.assertIn('data-value="g"', html)
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
html = FilterSelect(
field_name="platform",
options=[("1", "Steam"), ("2", "GOG")],
included=[("1", "Steam")],
excluded=[("2", "GOG")],
)
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
# symbol is a sibling text node.
self.assertIn('data-search-select-type="include"', html)
self.assertIn("", html)
self.assertIn(">Steam</span>", html)
self.assertIn('data-search-select-type="exclude"', html)
self.assertIn("", html)
self.assertIn(">GOG</span>", html)
self.assertIn("line-through", html) # excluded pill styling
def test_modifier_options_render_pinned_rows(self):
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
# so the text filter leaves them visible.
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
def test_active_modifier_replaces_value_pills(self):
html = FilterSelect(
field_name="platform",
options=[("1", "Steam")],
included=[("1", "Steam")],
modifier="IS_NULL",
modifier_options=self.MODIFIERS,
)
# The lone modifier pill is shown; include/exclude pills are suppressed.
# (Scope the check to the live pills region — the cloneable pill <template>s
# legitimately contain data-search-select-type.)
pills_region = html.split("data-search-select-template")[0]
self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html)
self.assertNotIn('data-search-select-type="include"', pills_region)
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
html = FilterSelect(
field_name="game",
search_url="/api/games/search",
prefetch=20,
modifier_options=self.MODIFIERS,
)
# No value rows in the live panel (they're fetched); the row prototype
# lives only in a <template>.
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) # still pinned
self.assertIn('data-prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self):
# A selected value outside the fetched window still shows its label.
html = FilterSelect(
field_name="game",
search_url="/api/games/search",
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
)
self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html)
class SearchLabelTest(django.test.TestCase): class SearchLabelTest(django.test.TestCase):
Generated
+48 -67
View File
@@ -6,6 +6,10 @@ resolution-markers = [
"python_full_version < '3.15'", "python_full_version < '3.15'",
] ]
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P7D"
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -149,25 +153,25 @@ wheels = [
[[package]] [[package]]
name = "distlib" name = "distlib"
version = "0.4.2" version = "0.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/8d/873e9252ea2c0e0c857884e0a2899ec43ade132345df1925ef24cbe64f18/distlib-0.4.2.tar.gz", hash = "sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db", size = 614914, upload-time = "2026-06-08T16:24:15.439Z" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/aa891c893821d4d127292ed66c6940d1d715894bd5a0ce048056bc641773/distlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067", size = 470510, upload-time = "2026-06-08T16:24:13.208Z" }, { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
] ]
[[package]] [[package]]
name = "django" name = "django"
version = "6.0.6" version = "6.0.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" },
] ]
[[package]] [[package]]
@@ -269,7 +273,7 @@ wheels = [
[[package]] [[package]]
name = "djlint" name = "djlint"
version = "1.39.0" version = "1.36.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -282,36 +286,13 @@ dependencies = [
{ name = "regex" }, { name = "regex" },
{ name = "tqdm" }, { name = "tqdm" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/aa/a7/5ba1032d01ceba641b92b1c76c758a0a06959585c6d36608371526809a08/djlint-1.39.0.tar.gz", hash = "sha256:75e7e1a0c592121751c48360104b3c402f4d6406ea862ba76f8867b3eb51ba97", size = 55174, upload-time = "2026-06-05T19:22:37.296Z" } sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d0/6055cebb538718e46b3874d3a1c0c768aaf744a1354f342b1932985c882b/djlint-1.39.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2948211eb369bd28175f2007cc924bff7e2403ec1f42f22f6d4381c32bad31", size = 517087, upload-time = "2026-06-05T19:22:40.617Z" }, { url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
{ url = "https://files.pythonhosted.org/packages/39/be/726afcd62b9ce6382d2c10a9122a45daf4a47b6e2af4a7536c82b8b5f4fc/djlint-1.39.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1476f077af638ba21813cc17d8e7d31b1d5473e707d98c659e6ac2bdf5210e6", size = 489869, upload-time = "2026-06-05T19:22:47.081Z" }, { url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/f26dc11c62111f6d80550e9188b2d207691f0664ed3b7dbd62ed5d418e32/djlint-1.39.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19dbef7852fabe445ce4ea2b05da888df0513e1798c4ae7cd8f0c68cf0bc8cbb", size = 513551, upload-time = "2026-06-05T19:23:13.49Z" }, { url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/2ffe28c44d27aa006314c1b352a0b6039ab05dd4b7b3dbac494315b912ab/djlint-1.39.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c8c7bba68633f6a4a211dd35ded9337ec52a7a2991afc816f928f741296c1b3", size = 537832, upload-time = "2026-06-05T19:22:30.67Z" }, { url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
{ url = "https://files.pythonhosted.org/packages/53/46/2cb7966a7a93b4758a380500c9a18fa22688b071dba5b52106107b48de4e/djlint-1.39.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5564bc51531332ba67bc8d952825ac2a42a7ec1618413a4da15bf957257c0d6", size = 520497, upload-time = "2026-06-05T19:23:19.497Z" }, { url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
{ url = "https://files.pythonhosted.org/packages/81/d0/b32648761b1529b030897b931998a6dabe6a15473c4724e1080c2ca737ae/djlint-1.39.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b836e79f690d83aa429cfa3240045e086f9e0764afbc88654004f455e2a9835a", size = 547304, upload-time = "2026-06-05T19:23:21.742Z" },
{ url = "https://files.pythonhosted.org/packages/26/6d/c0e7c61fdeee741ee7eec85a14dd40c8d2e1ee9efeb96a8a7302a8daef47/djlint-1.39.0-cp313-cp313-win32.whl", hash = "sha256:f18c148fc6cfb32dd8a0af7c80067f02d3faa83f5aea16a7c7fd5111d303ee69", size = 406746, upload-time = "2026-06-05T19:22:57.969Z" },
{ url = "https://files.pythonhosted.org/packages/74/c5/7ea676211bbb85665b2f82f2cc64925a4f54d866d57887ab943e97016fcf/djlint-1.39.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c38a8e90f8a73adf08b6852ee34bf3c734873f2ff1df58e56206308272cb275", size = 453441, upload-time = "2026-06-05T19:22:41.662Z" },
{ url = "https://files.pythonhosted.org/packages/04/49/3056c368937e98d6cb7d1ac662e64e93bc9b5ddf5a2afcd01839c0095a51/djlint-1.39.0-cp313-cp313-win_arm64.whl", hash = "sha256:e95095623cf5d6e84161c9a08e81f29ea5f7f1c804107ccf7cd2fe27a750a3bc", size = 388639, upload-time = "2026-06-05T19:22:53.201Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c2/76fa9ffa5b88784a2704b64f08d902bc8071a99bdd79a983f56b3e2dfcdf/djlint-1.39.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a092b0beb93d9a6fe5e1e28934e4f933c483ce791aae9aec47e3f07a29511a61", size = 515957, upload-time = "2026-06-05T19:23:09.12Z" },
{ url = "https://files.pythonhosted.org/packages/d5/44/638b92e40ad5b473df6728c3c6c7ebd9d50823d4cf8dd5bdf22073bd1d57/djlint-1.39.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ca3cd2c1ca610ad6e6357abba51e8153dc19f1d34764bcf453084199a4732a2", size = 488676, upload-time = "2026-06-05T19:22:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/27/b6/50e91d06554b74dc558a6af6349643c0165ff6dcc5142908ae2db012acca/djlint-1.39.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0011c2b78fa26752e3373129965dcbe80253af7fd2807e394fdfd4ea6281d99", size = 517217, upload-time = "2026-06-05T19:22:48.533Z" },
{ url = "https://files.pythonhosted.org/packages/77/2d/f9f900ae26b44b3b79090667148eeb016464cfe70d0211e2afe0fda9ab4c/djlint-1.39.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:683ec039c2864670f1806fc96e4650f3f7e310222acb5d602608aeb24ca352e9", size = 537472, upload-time = "2026-06-05T19:22:51.868Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ad/28ef34f629e728042341c397261fc2593a2eec489e44a7863cf646edc628/djlint-1.39.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:326a5ec019b084eb2d837f39d0bea6727806867e9d1e26d3f4bf0cd6bc67bf8f", size = 523546, upload-time = "2026-06-05T19:23:29.143Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6a/7ce68fdf319d9abda560fe3509d60abefe25ef118ae21d03399b1dfc84e7/djlint-1.39.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e655ac4e4346b3f5a61b53a9351104d33e4a7376f1c22acf4fadf1183f90128a", size = 546627, upload-time = "2026-06-05T19:22:31.67Z" },
{ url = "https://files.pythonhosted.org/packages/04/89/3e5bfaeb7b39a078a9a8d4fc7331e60f12f0e5c1251bc6c622be8c592ad4/djlint-1.39.0-cp314-cp314-win32.whl", hash = "sha256:0b5e30ab98c4de74698211ce6a60a502307d176015bf98269f74a39d862fc694", size = 412745, upload-time = "2026-06-05T19:22:35.955Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/b891316176513c233507dbf2f82747552e401079e3f917c46fbf84c5ef05/djlint-1.39.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d4927b1bf65445e3c8dda8d1b96ab3019dbce1eaa88850760df78962bf2724e", size = 462295, upload-time = "2026-06-05T19:23:05.893Z" },
{ url = "https://files.pythonhosted.org/packages/06/44/ba3bf57ee70e969407e96d7accfb13d00c776674dbce95f8b07e1c7f731f/djlint-1.39.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b6a684f5cd8fc71ad55cd3c1acffa0cd4108bc63ad1524f9ca1d76b1b354e47", size = 396557, upload-time = "2026-06-05T19:22:54.276Z" },
{ url = "https://files.pythonhosted.org/packages/26/c0/bdb3eb96bd8e5d65546fe63063b787e302b981ec2f1436b1a0027404c311/djlint-1.39.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4ba49d6b67f3c0145d78448c292e75d5822e76c189ef681399ead8492c599", size = 561022, upload-time = "2026-06-05T19:23:23.09Z" },
{ url = "https://files.pythonhosted.org/packages/96/98/e35b87ebc8f2a6985aed5ea7b85145d9e6e5d5b67fc3b612396a84604791/djlint-1.39.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fee96af514bd1cb6b62d1107bb177d4d2f49361e5e9cd14f56f9650cdc2b5ad", size = 534450, upload-time = "2026-06-05T19:22:33.683Z" },
{ url = "https://files.pythonhosted.org/packages/87/f4/3ff2615cc2826c91ec3c7c26e8abedb35b3a546a068bc70ef385b2079c17/djlint-1.39.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ef06848e1ed5d987bb1aaf950ffe3a87b14e5937d9d42dbb1d0469ebe7a74dc", size = 552149, upload-time = "2026-06-05T19:22:27.861Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fc/6fea3ea0075d06d1d5444a7ad72bf51c612795339e95d4b281599c61b9ee/djlint-1.39.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffcbca30ad41bc054c7c7ed5341ea651b034a60d4eff0aa2ab0bb8cb40f2b9b0", size = 570693, upload-time = "2026-06-05T19:22:55.293Z" },
{ url = "https://files.pythonhosted.org/packages/1e/6a/af8a4012652a33208b3e0ca04c23446711fa5ecf8936809c04c6213c47b8/djlint-1.39.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8aace5a239e5f317b030a5c05d22d55edac5142366ffa1a15e5e5c8675044e44", size = 557296, upload-time = "2026-06-05T19:23:24.545Z" },
{ url = "https://files.pythonhosted.org/packages/6e/13/bf86a4f5d140ab6052a3aca8742cb446ec851946c7dcb625eb18a2564893/djlint-1.39.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9912c361968a3c881fd3eaff5a5dc56a0a409a7904355d998d430ff294550744", size = 579052, upload-time = "2026-06-05T19:23:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/5d2850606e321f8d6e56fe74fcb283c12493d179279bb52f347d0338aa6e/djlint-1.39.0-cp314-cp314t-win32.whl", hash = "sha256:12d3175f48317ec692da693a15ce7b939b3114f16b8d644bb037784bcef0bd52", size = 457432, upload-time = "2026-06-05T19:23:04.728Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9f/6dc179c101d30c1aa4269e0cada79667c043d15392e515fb7e4e36e8a8df/djlint-1.39.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3077dc9a4b3bb2724cd0231f008d309fe4ef4048af06b7edd1adba723356248", size = 513546, upload-time = "2026-06-05T19:23:11.375Z" },
{ url = "https://files.pythonhosted.org/packages/d9/0d/e3acb7da4ce3df5d699412b9442b885286df7e45647c205d65e593d02711/djlint-1.39.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f7228e01d5ceaf74fb5270d7bdfbd30dffe65e88216a70824765bca6acb2a4fb", size = 412286, upload-time = "2026-06-05T19:22:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/bd/45/50bddcbcee9566c213f14db5b154ade285c4842b88cdcdcc8d536d515147/djlint-1.39.0-py3-none-any.whl", hash = "sha256:3ef41f7bbf7761978e86e24ebdaf58704b17d847e9d0b5d9cb9f761ce976cff0", size = 60750, upload-time = "2026-06-05T19:23:02.846Z" },
] ]
[[package]] [[package]]
@@ -325,11 +306,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.29.1" version = "3.29.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
] ]
[[package]] [[package]]
@@ -364,11 +345,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.17"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
] ]
[[package]] [[package]]
@@ -840,27 +821,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.16" version = "0.15.15"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
] ]
[[package]] [[package]]
@@ -946,14 +927,14 @@ dev = [
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.68.1" version = "4.67.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload-time = "2026-06-05T17:23:15.267Z" } sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload-time = "2026-06-05T17:23:13.654Z" }, { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
] ]
[[package]] [[package]]
@@ -1010,7 +991,7 @@ wheels = [
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.4.2" version = "21.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
@@ -1018,7 +999,7 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "python-discovery" }, { name = "python-discovery" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } sdist = { url = "https://files.pythonhosted.org/packages/95/f0/b47ecf438211a25a97f8f0e4b23c22bc2496ebfea18dd6ec16210f09cc36/virtualenv-21.4.1.tar.gz", hash = "sha256:2ca543c713b72840ceffd94e9bdedfbd09a661defa1f7f69e5429ad4059442e2", size = 7613344, upload-time = "2026-05-28T04:12:49.905Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, { url = "https://files.pythonhosted.org/packages/ff/dc/ac4f3a987a87e1a18556896f257c4e15c95ed157b7975347ec6b313b75ce/virtualenv-21.4.1-py3-none-any.whl", hash = "sha256:caf4ff72d1b4039057f41d8e8466e859513d67c0400d9c6b62c02c9d1ebc3e12", size = 7594078, upload-time = "2026-05-28T04:12:47.686Z" },
] ]