Compare commits

..

25 Commits

Author SHA1 Message Date
Claude 05534875d6 Remove dead code and fix stale comments in filters.py
Django CI/CD / test (push) Successful in 46s
Django CI/CD / build-and-push (push) Successful in 1m12s
- Remove _filter_number() — defined but never called; take _FILTER_INPUT_CLASS
  with it since it was only used there.
- Remove the isinstance(value/excluded, str) single-string guards in
  _filter_get_choice — JS always emits arrays, this was backward-compat
  dead code.
- Remove identity-copy list comprehensions in PurchaseFilterBar; pass
  Purchase.TYPES and Purchase.OWNERSHIP_TYPES directly.
- Fix stale section comment that said model fields "resolve selected ids
  to labels" — they now use labels embedded in the filter JSON.
- Drop the now-unused escape import.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 428edbcfe8 Remove bare-value fallback from _extract_labeled
The JS always emits {id, label} objects now; the else branch was dead code
and the docstring was wrong. Update the remaining test that was still
passing bare strings.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 11cd62a3b9 Introduce LabeledOption and RangeValues named types
Replace all tuple[str, str] annotations with purpose-specific names:
- LabeledOption = tuple[str, str] for (value, label) pairs used in
  FilterChoice, FilterSelect params, _modifier_options, _find_label,
  and _extract_labeled.
- RangeValues(min, max) NamedTuple for _parse_range return values,
  making the two fields self-documenting at every call site.

Export LabeledOption from common.components alongside SearchSelectOption.
Document the "name compound types explicitly" convention in CLAUDE.md.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude d9902146dc Clean up label-embedding architecture
- Move {id,label} stripping into _SetCriterion.from_json() so both
  MultiCriterion and ChoiceCriterion normalise at the parse boundary;
  the querying layer stays typed (list[int] / list[str]) and clean.
- Revert MultiCriterion to a thin _extra_q() override; _SetCriterion.to_q()
  is no longer duplicated.
- JS: readSearchSelect always emits {id, label} objects — no conditional
  mixed-type arrays. filter_bar.js stores them as-is for all fields,
  removing the fragile isIdField hardcoded list.
- Update tests to use the {id, label} filter format.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 83cbac9505 Update uv.lock
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 0285243172 Embed labels in filter criteria (Stash-style) to retire pill resolver
Store {id, label} objects instead of bare IDs in MultiCriterion value/excludes.
FilterSelect pills now render directly from the embedded labels — no DB round-trip
to _resolve_game/device/platform_options. The filter URL and saved presets are
self-describing. MultiCriterion.to_q() extracts ids for querying; bare ints are
still accepted for backward compatibility.

Closes #9

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 112d3107ef Consolidate Multi/Choice criteria into a shared _SetCriterion base
MultiCriterion and ChoiceCriterion were near-duplicate copies whose INCLUDES
branches had drifted — the exclude-only bug existed in one but not the other.
Extract the shared include/exclude/null set-membership logic into a _SetCriterion
base implemented once (INCLUDES with empty-list guards, EQUALS as an alias,
IS_NULL/NOT_NULL); subclasses contribute only their value type and their own
modifiers via _extra_q (INCLUDES_ALL for Multi; EXCLUDES/NOT_EQUALS for Choice).
Behaviour preserved (full modifier vocabulary kept); the duplication that caused
the drift is gone. Surfacing the modifier axis and harmonizing EXCLUDES is
tracked in #10.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 22d7834ae9 Fix exclude-only multi filters matching nothing
MultiCriterion.to_q (used by SessionFilter for game/device) unconditionally added
field__in=value even when value was empty, and __in=[] matches no rows — so a
filter with only excludes (e.g. device excludes 11, no game/device includes)
returned zero results. Guard the empty value like ChoiceCriterion already does,
so an exclude-only criterion means 'all rows except the excluded ids'.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
lukas 60773e7755 Fix variable names 2026-06-08 19:12:28 +02:00
Claude 79fa4bef44 Use element primitives instead of inline Component; add Template primitive
Add a Template() primitive for the standard <template> tag and export it. Replace
inline Component(tag_name="div"/"span"/"input"/"template") in search_select.py
and Pill with Div/Span/Input/Template; drop the private _template helper in favour
of Template at the call sites. Bare custom-styled <button>s stay on Component
(the opinionated Button() would inject unwanted classes). Document the
prefer-primitives convention in CLAUDE.md.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 15bb3ce1b9 Expand the ss namespace prefix to search-select everywhere
Spell out the abbreviated data-ss-* hook attributes (data-search-select-option,
-label, -mode, -template, -action, -type, -modifier, -modifier-option, -pills,
-search, -options, -no-results) and the JS expando properties (_searchSelectInit,
_searchSelectLabel, _searchSelectDirty, _searchSelectOption) across components,
JS, and tests — no abbreviations left in the widget's hooks.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude a06e772e42 Rename data-ss-tpl attribute to data-ss-template
Spell out the abbreviation in the template marker attribute too, matching the
complete-words convention applied to the variables.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 29b42e0f3d Use complete words for variable names; document the convention
Rename abbreviated identifiers in the PR's code to full words: tpl→template,
e→event, el→element, removeBtn→removeButton, and single-letter loop variables
(o→option, g/d/p→game/device/platform, v→value/modifier_value). Add a
'name variables with complete words' convention to CLAUDE.md.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude f210f818a9 Single-source combobox markup via <template> cloning
Eliminate the Python/JS class-string duplication: the server renders hidden
<template> prototypes (row, pill, include/exclude/modifier pills) using the same
component functions, and search_select.js clones them, filling only the
[data-ss-label] slot, value, and data-* attrs. All Tailwind class strings and DOM
structure now live solely in the Python components — the JS no longer hardcodes
any class. Pill gains an opt-in label_slot; the shell takes a templates list.

Companion issue #8 tracks the further HTMX-idiomatic step of returning rendered
row HTML from the search endpoint.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
lukas 6bc7da9f2f Fix button visibility on select option hover 2026-06-08 19:12:28 +02:00
lukas c9189b9f8e Update allowed builders for pnpm 2026-06-08 19:12:28 +02:00
lukas a37257f9c8 Update uv.lock security 2026-06-08 19:12:28 +02:00
Claude db047dfaf2 Fix dropdown overlapping the search box
Anchor the options panel with top-full. As an absolutely positioned child of the
now-flex field container, its static position was centered by items-center,
placing the dropdown over the search box instead of below it.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 6aff12b7b2 Address PR review: combobox field layout and dark-mode contrast
- Wire the long-defined-but-unused _FIELD_CLASS into the container so pills and
  the search input form a single padded flex row; the flex-1 input now fills the
  widget instead of looking unclickable inside a larger box (affects both
  SearchSelect and FilterSelect via the shared shell).
- Filter option labels get text-body so they're readable on dark backgrounds.
- Filter +/- buttons get text-body (readable at rest) and hover:border-brand-strong
  so the border stays visible against the brand hover fill.
- Mirror the filter class changes in search_select.js and rebuild base.css.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 12b0b0af61 Remove the bespoke SelectableFilter widget
FilterSelect fully replaces it: delete SelectableFilter and its _selectable_*
helpers, the now-unused _get_filter_options, selectable_filter.js, and the .sf-*
rules in input.css (rebuilt base.css). The three list views load search_select.js
instead of selectable_filter.js. Drop the SelectableFilter export and refresh
docs/comments that referenced it.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 1a206d719b Migrate filter bars to FilterSelect
Replace the bespoke SelectableFilter in all three bars with FilterSelect: enum
fields (status, type, ownership) pre-render their fixed options; model-backed
fields (game(s), platform, device) use the search endpoints with prefetch and
resolve only the selected ids to pill labels — dropping the per-page queries that
fetched every game/platform/device. filter_bar.js now reads filter-mode
SearchSelect widgets via readSearchSelect (data-included/excluded/modifier),
preserving the {value, excludes, modifier} JSON and id Number() coercion; the
redundant session game/device blocks are gone. Drop FilterBar's now-unused
platform_options param. Rebuild base.css for the inline filter-pill utilities and
update the bar tests to the new markup.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude a6532807cb Wire filter-mode behavior into search_select.js
Dispatch on data-ss-mode: in filter mode, value rows (server-rendered or fetched
via buildRow) carry +/- buttons that add include/exclude pills, and pinned
modifier pseudo-options set a lone, mutually-exclusive modifier pill. Pill removal
handles the modifier flag; filter pills carry no hidden inputs. Extend
readSearchSelect to serialise filter widgets into data-included / data-excluded /
data-modifier (the shape the filter bar consumes), leaving form widgets'
data-values path unchanged. JS class strings mirror the FilterSelect constants.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude d7e6efa68a Add FilterSelect: include/exclude combobox on the shared shell
FilterSelect renders value rows with +/- (include/exclude) buttons, check/cross
pills for the included/excluded sets, and an optional set of pinned modifier
pseudo-options (e.g. (Any)/(None)) that stay visible above the value rows. A
selected modifier is mutually exclusive with value pills. It delegates assembly
to _combobox_shell and supports both pre-rendered options (complete set) and
search_url + prefetch (windowed); included/excluded are passed as resolved
value+label so pills show labels even outside the fetched window. Styling is
inline (ported from the old SelectableFilter CSS) so nothing lives in input.css.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 003e6ebe15 Add prefetch + instant-local/debounced-remote search to combobox
Introduce a general 'prefetch' option (rows to load on first open, default 0 =
unchanged) carried as data-prefetch. Rework the JS search so a search_url widget
filters its loaded window instantly on every keystroke while issuing a debounced
server request for the rest, with an AbortController so a slower earlier response
can never overwrite a newer one. No-results stays hidden until the server
response decides it, avoiding a flash over an incomplete window. On first focus a
prefetch-enabled widget seeds its window immediately. Rename single-letter locals
to full words while reworking these functions.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude e2cbd4a9f4 Extract _combobox_shell from SearchSelect
Pull the domain-agnostic combobox skeleton (pills region, search box, options
panel with its no-results node, outer container) into a private _combobox_shell
helper. SearchSelect now builds its form-specific pills and option rows and
delegates assembly to the shell. Rendered markup is byte-identical; a structural
test guards the fixed region order so future builders (e.g. a filter variant)
can share the shell without drift.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
18 changed files with 1157 additions and 999 deletions
+6 -4
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.
- **`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()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips)
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`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`
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
@@ -118,8 +118,7 @@ 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`
- **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
- `selectable_filter.js` — SelectableFilter widget interaction
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment
@@ -159,10 +158,13 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
## 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.
- **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`.
- **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`.
- **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`.
- **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`.
- **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.
+6 -2
View File
@@ -36,10 +36,13 @@ from common.components.primitives import (
TableHeader,
TableRow,
TableTd,
Template,
YearPicker,
paginated_table_content,
)
from common.components.search_select import (
FilterSelect,
LabeledOption,
SearchSelect,
SearchSelectOption,
searchselect_selected,
@@ -58,7 +61,6 @@ from common.components.domain import (
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SelectableFilter,
SessionFilterBar,
)
@@ -85,6 +87,8 @@ __all__ = [
"Popover",
"PopoverTruncated",
"SearchField",
"FilterSelect",
"LabeledOption",
"SearchSelect",
"SearchSelectOption",
"searchselect_selected",
@@ -94,6 +98,7 @@ __all__ = [
"TableHeader",
"TableRow",
"TableTd",
"Template",
"YearPicker",
"paginated_table_content",
"GameLink",
@@ -107,6 +112,5 @@ __all__ = [
"_resolve_name_with_icon",
"FilterBar",
"PurchaseFilterBar",
"SelectableFilter",
"SessionFilterBar",
]
+106 -290
View File
@@ -1,33 +1,38 @@
"""Stash-style filter bars and the SelectableFilter widget."""
"""Stash-style filter bars, built from FilterSelect widgets."""
from typing import NamedTuple
from django.db import models
from django.utils.html import escape
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component
from common.components.primitives import Label, Span
from common.components.search_select import FilterSelect, LabeledOption
class FilterChoice(NamedTuple):
"""Parsed state of a SelectableFilter widget from a filter JSON blob."""
"""Parsed include/exclude/modifier state of a filter field from filter JSON.
selected: list[str]
excluded: list[str]
``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For
model-backed fields the label is embedded in the filter JSON (Stash-style);
for enum fields the label is resolved from the fixed option list.
"""
selected: list[LabeledOption]
excluded: list[LabeledOption]
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_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 = (
"rounded border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand"
@@ -49,30 +54,28 @@ def _filter_parse(filter_json: str) -> dict:
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:
raw = existing.get(field, {})
if not isinstance(raw, dict):
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(
selected=[str(v) for v in (value or [])],
excluded=[str(v) for v in (excluded or [])],
modifier=modifier or "",
selected=_extract_labeled(raw.get("value") or []),
excluded=_extract_labeled(raw.get("excludes") or []),
modifier=raw.get("modifier") or "",
)
def _parse_range(existing: dict, key: str) -> tuple[str, str]:
"""Extract (value, value2) from a filter criterion, defaulting to ("", "")."""
def _parse_range(existing: dict, key: str) -> RangeValues:
"""Extract (min, max) from a range filter criterion, defaulting to ("", "")."""
field = existing.get(key, {})
if not isinstance(field, dict):
return "", ""
return str(field.get("value", "")), str(field.get("value2", ""))
return RangeValues("", "")
return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
def _parse_bool(existing: dict, key: str) -> bool:
@@ -83,20 +86,58 @@ def _parse_bool(existing: dict, key: str) -> bool:
return bool(field.get("value", False))
def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]:
"""Return (value, label) pairs for a SelectableFilter from model rows.
# ── FilterSelect adapters ────────────────────────────────────────────────────
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
# 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.
Uses values_list for efficiency (only fetches needed columns),
but unpacks each row into readable local variables.
"""
options: list[tuple[str, str]] = []
for object_id, object_name in model_class.objects.order_by(order_by).values_list(
"id", order_by
):
options.append((str(object_id), object_name))
_FILTER_PREFETCH = 20
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(
field_name=field_name,
included=[(value, label or value) for value, label in choice.selected],
excluded=[(value, label or value) for value, label in choice.excluded],
modifier=choice.modifier,
modifier_options=_modifier_options(nullable),
search_url=search_url,
prefetch=_FILTER_PREFETCH,
)
def _filter_mins_to_hrs(val) -> str:
if val is None or val == "" or val == 0:
return ""
@@ -125,23 +166,6 @@ 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:
return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
@@ -347,8 +371,7 @@ def RangeSlider(
("data-target", min_input_id),
(
"style",
"left:0"
+ (";display:none" if point_mode else ""),
"left:0" + (";display:none" if point_mode else ""),
),
],
),
@@ -564,23 +587,19 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
def FilterBar(
filter_json: str = "",
status_options: list[tuple[str, str]] | None = None,
platform_options: list[tuple[int, str]] | None = None,
status_options: list[LabeledOption] | None = None,
preset_list_url: str = "",
preset_save_url: str = "",
) -> SafeText:
"""Collapsible filter bar for the Game list."""
from games.models import Game, Platform
from games.models import Game
if status_options is None:
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)
status_choice = _filter_get_choice(existing, "status")
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")
mastered_value = _parse_bool(existing, "mastered")
@@ -617,23 +636,19 @@ def FilterBar(
children=[
_filter_field(
"Status",
SelectableFilter(
_enum_filter(
"status",
status_options,
status_choice.selected,
status_choice.excluded,
status_choice.modifier,
status_choice,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
SelectableFilter(
_model_filter(
"platform",
platform_options_str,
platform_choice.selected,
platform_choice.excluded,
platform_choice.modifier,
platform_choice,
search_url="/api/platforms/search",
nullable=Game._meta.get_field("platform").null,
),
),
@@ -671,190 +686,7 @@ def FilterBar(
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
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:
def _find_label(options: list[LabeledOption], value: str) -> str:
for v, label in options:
if str(v) == str(value):
return label
@@ -865,10 +697,8 @@ def SessionFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Session list."""
from games.models import Device, Game, Session
from games.models import Game, Session
game_options = _get_filter_options(Game)
device_options = _get_filter_options(Device)
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device")
@@ -898,23 +728,19 @@ def SessionFilterBar(
children=[
_filter_field(
"Game",
SelectableFilter(
_model_filter(
"game",
game_options,
game_choice.selected,
game_choice.excluded,
game_choice.modifier,
game_choice,
search_url="/api/games/search",
nullable=not Game._meta.get_field("name").has_default(),
),
),
_filter_field(
"Device",
SelectableFilter(
_model_filter(
"device",
device_options,
device_choice.selected,
device_choice.excluded,
device_choice.modifier,
device_choice,
search_url="/api/devices/search",
nullable=Session._meta.get_field("device").null,
),
),
@@ -946,12 +772,10 @@ def PurchaseFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Purchase list."""
from games.models import Game, Platform, Purchase
from games.models import Purchase
game_options = _get_filter_options(Game)
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]
type_options = Purchase.TYPES
ownership_options = Purchase.OWNERSHIP_TYPES
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "games")
platform_choice = _filter_get_choice(existing, "platform")
@@ -975,45 +799,37 @@ def PurchaseFilterBar(
children=[
_filter_field(
"Game",
SelectableFilter(
_model_filter(
"games",
game_options,
game_choice.selected,
game_choice.excluded,
game_choice.modifier,
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
_filter_field(
"Platform",
SelectableFilter(
_model_filter(
"platform",
platform_options,
platform_choice.selected,
platform_choice.excluded,
platform_choice.modifier,
platform_choice,
search_url="/api/platforms/search",
nullable=Purchase._meta.get_field("platform").null,
),
),
_filter_field(
"Type",
SelectableFilter(
_enum_filter(
"type",
type_options,
type_choice.selected,
type_choice.excluded,
type_choice.modifier,
type_choice,
nullable=not Purchase._meta.get_field("type").has_default(),
),
),
_filter_field(
"Ownership",
SelectableFilter(
_enum_filter(
"ownership_type",
ownership_options,
ownership_choice.selected,
ownership_choice.excluded,
ownership_choice.modifier,
ownership_choice,
nullable=not Purchase._meta.get_field(
"ownership_type"
).has_default(),
+22 -2
View File
@@ -369,6 +369,16 @@ def Label(
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
# 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
@@ -386,6 +396,7 @@ def Pill(
value: str = "",
removable: bool = False,
extra_class: str = "",
label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A small label pill, optionally removable (× button).
@@ -393,6 +404,10 @@ def Pill(
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
are JS hooks only (no CSS attached). ``value`` (when set) becomes
``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 []
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
@@ -401,7 +416,12 @@ def Pill(
pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes)
children: list[HTMLTag] = [label]
label_child: HTMLTag = (
Span(attributes=[("data-search-select-label", "")], children=[label])
if label_slot
else label
)
children: list[HTMLTag] = [label_child]
if removable:
children.append(
Component(
@@ -416,7 +436,7 @@ def Pill(
)
)
return Component(tag_name="span", attributes=pill_attrs, children=children)
return Span(attributes=pill_attrs, children=children)
def CsrfInput(request) -> SafeText:
+387 -46
View File
@@ -7,6 +7,15 @@ hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``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
@@ -15,7 +24,7 @@ from typing import TypedDict
from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute
from common.components.primitives import Pill
from common.components.primitives import Div, Input, Pill, Span, Template
class SearchSelectOption(TypedDict):
@@ -24,20 +33,32 @@ class SearchSelectOption(TypedDict):
data: dict[str, str] # becomes data-* attrs on the row / pill
# removed border and border-default-medium, see later if it's needed
_CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium"
# The pills and the search box share one flex-wrap row so the widget reads as a
# 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"
# A lightweight (value, label) pair used wherever only those two fields are
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
# SearchSelectOption adds a ``data`` dict for extra row attributes.
LabeledOption = tuple[str, str]
# The pills and the search box share one flex-wrap row (with padding) so the
# 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"
_SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
"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 = (
"absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium "
"rounded-base bg-neutral-secondary-medium shadow-lg"
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
"border border-default-medium 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"
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
@@ -46,6 +67,41 @@ _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
# used to derive the panel's max-height from items_visible.
_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:
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
@@ -64,23 +120,80 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
def _hidden_input(name: str, value) -> SafeText:
return Component(
tag_name="input",
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
)
return Input(type="hidden", attributes=[("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:
return Component(
tag_name="div",
return Div(
attributes=[
("data-ss-option", ""),
("data-search-select-option", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("class", _OPTION_ROW_CLASS),
*_data_attributes(option["data"]),
],
children=[option["label"]],
children=[_label_slot(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 [])],
)
@@ -94,20 +207,21 @@ def SearchSelect(
always_visible: bool = False,
items_visible: int = 5,
items_scroll: int = 10,
prefetch: int = 0,
placeholder: str = "Search…",
id: str = "",
sync_url: bool = False,
autofocus: bool = False,
) -> SafeText:
"""Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(o) for o in (selected or [])]
options = [_normalize_option(o) for o in (options or [])]
selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(option) for option in (options or [])]
# ── Pills + their hidden inputs (the submitted channel) ──
# Multi-select renders a removable Pill per value; single-select renders no
# 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
# `[data-ss-pills]` so the JS reads/writes values uniformly.
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = []
search_value = ""
if multi_select:
@@ -117,6 +231,7 @@ def SearchSelect(
option["label"],
value=str(option["value"]),
removable=True,
label_slot=True,
attributes=_data_attributes(option["data"]),
)
)
@@ -126,16 +241,14 @@ def SearchSelect(
pills_children.append(_hidden_input(name, option["value"]))
search_value = option["label"]
pills = Component(
tag_name="div",
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
pills = Div(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attrs: list[HTMLAttribute] = [
("data-ss-search", ""),
("type", "text"),
("data-search-select-search", ""),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
@@ -144,27 +257,29 @@ def SearchSelect(
search_attrs.append(("autofocus", ""))
if 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) ──
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"],
option_rows = [_option_row(option) for option in options] if not search_url else []
# ── Templates the JS clones: a row when results are fetched, a pill when
# 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)],
)
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],
)
container_attrs: list[HTMLAttribute] = [
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-name", name),
("data-search-url", search_url),
@@ -172,16 +287,242 @@ def SearchSelect(
("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS),
]
if id:
container_attrs.append(("id", id))
container_attributes.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(
tag_name="div",
attributes=container_attrs,
children=[pills, search, options_panel],
tag_name="button",
attributes=[
("type", "button"),
("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,
)
@@ -196,4 +537,4 @@ def searchselect_selected(
"""
if not values:
return []
return [_normalize_option(o) for o in resolver(values)]
return [_normalize_option(option) for option in resolver(values)]
+73 -49
View File
@@ -267,76 +267,100 @@ class BoolCriterion(_Criterion):
@dataclass
class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
class _SetCriterion(_Criterion):
"""Shared base for set-membership criteria (``MultiCriterion`` /
``ChoiceCriterion``).
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
``value`` is the include set and ``excludes`` the exclude set. The common
modifiers are implemented once here so the two subclasses cannot drift:
def to_q(self, field_name: str) -> Q:
m = self.modifier
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")
- ``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.
@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.
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by
overriding ``_extra_q``.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
value: list = field(default_factory=list)
excludes: list = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
modifier = self.modifier
if modifier in (Modifier.INCLUDES, 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.EXCLUDES:
if modifier == Modifier.IS_NULL:
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()
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.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:
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
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")
return None
# ── OperatorFilter base ────────────────────────────────────────────────────
-45
View File
@@ -232,48 +232,3 @@ 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;
}
+48 -165
View File
@@ -811,6 +811,9 @@
.static {
position: static;
}
.sticky {
position: sticky;
}
.inset-0 {
inset: calc(var(--spacing) * 0);
}
@@ -1285,6 +1288,9 @@
.ml-1 {
margin-left: calc(var(--spacing) * 1);
}
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
@@ -2074,6 +2080,12 @@
.bg-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 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2179,6 +2191,12 @@
.bg-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 {
background-color: var(--color-red-600);
}
@@ -2562,6 +2580,9 @@
.text-amber-500 {
color: var(--color-amber-500);
}
.text-amber-600 {
color: var(--color-amber-600);
}
.text-amber-800 {
color: var(--color-amber-800);
}
@@ -2658,12 +2679,18 @@
.italic {
font-style: italic;
}
.line-through {
text-decoration-line: line-through;
}
.no-underline\! {
text-decoration-line: none !important;
}
.underline {
text-decoration-line: underline;
}
.decoration-red-400 {
text-decoration-color: var(--color-red-400);
}
.decoration-slate-500 {
text-decoration-color: var(--color-slate-500);
}
@@ -2913,6 +2940,13 @@
}
}
}
.hover\:border-brand-strong {
&:hover {
@media (hover: hover) {
border-color: var(--color-brand-strong);
}
}
}
.hover\:border-default {
&:hover {
@media (hover: hover) {
@@ -2934,6 +2968,13 @@
}
}
}
.hover\:bg-brand {
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
}
.hover\:bg-brand-strong {
&:hover {
@media (hover: hover) {
@@ -2993,6 +3034,13 @@
}
}
}
.hover\:bg-neutral-secondary-strong {
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.hover\:bg-neutral-tertiary-medium {
&:hover {
@media (hover: hover) {
@@ -4381,171 +4429,6 @@ form input:disabled, select:disabled, textarea:disabled {
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 {
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;
+19 -50
View File
@@ -59,62 +59,31 @@
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── Generic SelectableFilter widgets ──
readSelectableFilters(form);
var widgets = form.querySelectorAll("[data-selectable-filter]");
widgets.forEach(function (w) {
var field = w.getAttribute("data-selectable-filter");
var inc = parseJSONAttr(w, "data-included");
var exc = parseJSONAttr(w, "data-excluded");
var mod = w.getAttribute("data-modifier");
if (mod === "NOT_NULL" || mod === "IS_NULL") {
filter[field] = { modifier: mod };
} else if (inc.length > 0 || exc.length > 0) {
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
// ── FilterSelect widgets (data-search-select-mode="filter") ──
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
readSearchSelect(form);
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
widgets.forEach(function (widget) {
var field = widget.getAttribute("data-name");
var included = parseJSONAttr(widget, "data-included");
var excluded = parseJSONAttr(widget, "data-excluded");
var modifier = widget.getAttribute("data-modifier");
if (modifier === "NOT_NULL" || modifier === "IS_NULL") {
filter[field] = { modifier: modifier };
} else if (included.length > 0 || excluded.length > 0) {
// All filter pills carry {id, label}; store them as-is so the filter
// URL and saved presets are self-describing (Stash-style).
filter[field] = {
value: isIdField ? inc.map(Number) : inc,
excludes: isIdField ? exc.map(Number) : exc,
modifier: mod || "INCLUDES",
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
modifier: modifier || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="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",
};
}
}
var pageIsSessions =
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
+242 -96
View File
@@ -6,49 +6,53 @@
* 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.
*
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
* each widget guarded with el._ssInit.
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
* carry +/ buttons that add include (✓) / exclude (✗) pills, plus pinned
* 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.
*
* The pill / option class strings below are kept byte-identical to the Python
* Pill / SearchSelect components so Tailwind generates the classes and
* server-rendered and JS-created pills are indistinguishable.
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
* element._searchSelectInit.
*
* 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 () {
"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;
function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (el) {
if (el._ssInit) return;
el._ssInit = true;
initWidget(el);
document.querySelectorAll("[data-search-select]").forEach(function (element) {
if (element._searchSelectInit) return;
element._searchSelectInit = true;
initWidget(element);
});
}
function initWidget(container) {
var search = container.querySelector("[data-ss-search]");
var options = container.querySelector("[data-ss-options]");
var pills = container.querySelector("[data-ss-pills]");
var search = container.querySelector("[data-search-select-search]");
var options = container.querySelector("[data-search-select-options]");
var pills = container.querySelector("[data-search-select-pills]");
if (!search || !options || !pills) return;
var name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url");
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
var multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
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 noResults = options.querySelector("[data-ss-no-results]");
var noResults = options.querySelector("[data-search-select-no-results]");
var debounceTimer = null;
var pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false;
function showPanel() {
options.classList.remove("hidden");
@@ -63,115 +67,229 @@
// ── Render server-fetched rows into the panel ──
function renderRows(items) {
options.querySelectorAll("[data-ss-option]").forEach(function (r) {
r.remove();
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
row.remove();
});
items.slice(0, itemsScroll).forEach(function (item) {
options.insertBefore(buildRow(item), noResults || null);
});
setNoResults(items.length === 0);
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) {
var row = document.createElement("div");
row.setAttribute("data-ss-option", "");
var row = cloneTemplate("row");
if (!row) return document.createComment("ss-row");
row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label);
row.className = OPTION_ROW_CLASS;
var data = option.data || {};
Object.keys(data).forEach(function (key) {
row.setAttribute("data-" + key, data[key]);
});
row.textContent = option.label;
row._ssOption = option;
applyData(row, option.data);
setLabel(row, option.label);
row._searchSelectOption = option;
return row;
}
// ── Client-side filter of pre-rendered rows ──
function filterRows(q) {
var lower = q.toLowerCase();
var anyVisible = false;
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
// ── Client-side filter of the currently loaded rows. Returns the number of
// visible rows so the caller decides whether to show the no-results node. ──
function filterRows(query) {
var lower = query.toLowerCase();
var visibleCount = 0;
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none";
if (match) anyVisible = true;
if (match) visibleCount += 1;
});
setNoResults(!anyVisible);
showPanel();
return visibleCount;
}
function runSearch() {
var q = search.value.trim();
if (searchUrl && q) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () {
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
credentials: "same-origin",
// ── 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 (r) {
return r.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);
})
.then(renderRows)
.catch(function () {
.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();
if (searchUrl) {
filterRows(query);
setNoResults(false);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () {
fetchFromServer(query);
}, DEBOUNCE_MS);
} else {
filterRows(q);
setNoResults(filterRows(query) === 0);
}
}
// ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._ssLabel = search.value;
if (!multi) container._searchSelectLabel = search.value;
search.addEventListener("focus", function () {
if (!multi) {
// Hide the committed label so the box becomes a fresh search field.
search.value = "";
container._ssDirty = false;
container._searchSelectDirty = 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 () {
if (!multi) container._ssDirty = true;
if (!multi) container._searchSelectDirty = true;
runSearch();
});
if (!multi) {
search.addEventListener("blur", function () {
// Defer so an option click (which fires before blur settles) wins.
setTimeout(function () {
if (container._ssDirty && search.value.trim() === "") {
if (container._searchSelectDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect.
pills.innerHTML = "";
container._ssLabel = "";
container._searchSelectLabel = "";
emitChange(null);
} else {
// Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection).
search.value = container._ssLabel || "";
search.value = container._searchSelectLabel || "";
}
}, 120);
});
}
// Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", function (e) {
e.preventDefault();
options.addEventListener("mousedown", function (event) {
event.preventDefault();
});
// ── Option click → select ──
options.addEventListener("click", function (e) {
var row = e.target.closest("[data-ss-option]");
// ── Option click → select (form mode) or include/exclude (filter mode) ──
options.addEventListener("click", function (event) {
if (isFilter) {
handleFilterOptionClick(event);
return;
}
var row = event.target.closest("[data-search-select-option]");
if (!row) return;
var option = optionFromRow(row);
selectOption(option);
selectOption(optionFromRow(row));
});
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) {
if (row._ssOption) return row._ssOption;
if (row._searchSelectOption) return row._searchSelectOption;
var data = {};
Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") {
@@ -192,39 +310,29 @@
}
} else {
// Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-ss-pills] for submission.
// lone hidden input under [data-search-select-pills] for submission.
pills.innerHTML = "";
pills.appendChild(buildHidden(option.value));
search.value = option.label;
container._ssLabel = option.label;
container._ssDirty = false;
container._searchSelectLabel = option.label;
container._searchSelectDirty = false;
hidePanel();
}
emitChange(option);
}
function addPill(option) {
pills.appendChild(buildPill(option));
var pill = buildPill(option);
if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value));
}
function buildPill(option) {
var pill = document.createElement("span");
pill.className = PILL_CLASS;
pill.setAttribute("data-pill", "");
var pill = cloneTemplate("pill");
if (!pill) return null;
pill.setAttribute("data-value", option.value);
var data = option.data || {};
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);
applyData(pill, option.data);
setLabel(pill, option.label);
return pill;
}
@@ -237,11 +345,21 @@
}
// ── Pill × → remove ──
pills.addEventListener("click", function (e) {
var removeBtn = e.target.closest("[data-pill-remove]");
if (!removeBtn) return;
var pill = removeBtn.closest("[data-pill]");
pills.addEventListener("click", function (event) {
var removeButton = event.target.closest("[data-pill-remove]");
if (!removeButton) return;
var pill = removeButton.closest("[data-pill]");
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");
pill.remove();
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
@@ -288,8 +406,8 @@
}
// ── Close panel on outside click ──
document.addEventListener("click", function (e) {
if (!container.contains(e.target)) hidePanel();
document.addEventListener("click", function (event) {
if (!container.contains(event.target)) hidePanel();
});
}
@@ -298,11 +416,39 @@
return String(value).replace(/["\\]/g, "\\$&");
}
// Forward-looking hook (parallels readSelectableFilters): write each widget's
// current values to a data-values JSON attribute.
// Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read.
window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-ss-pills]");
var pills = container.querySelector("[data-search-select-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
? Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'),
-149
View File
@@ -1,149 +0,0 @@
/**
* 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,
title="Manage games",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
+1 -1
View File
@@ -142,7 +142,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
content,
title="Manage purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
+1 -1
View File
@@ -182,7 +182,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
content,
title="Manage sessions",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
+4 -23
View File
@@ -15,7 +15,6 @@ from django.test import TestCase
from common.components import (
FilterBar,
PurchaseFilterBar,
SelectableFilter,
SessionFilterBar,
)
from games.models import Device, Game, Platform
@@ -94,14 +93,15 @@ class FilterBarRenderingTest(TestCase):
self._assert_range_slider(html)
def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as a selected tag in the widget."""
filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}})
"""A status in filter_json renders as an include pill in the widget."""
filter_json = json.dumps({"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}})
html = str(
FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn("sf-tag", html)
self.assertIn('data-search-select-mode="filter"', 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("Finished", html) # ...with its label
self.assertNoEscapedTags(html)
@@ -110,22 +110,3 @@ class FilterBarRenderingTest(TestCase):
# for the double-escape bug the dedup fixed.
self.assertIn("&quot;status&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)
+50 -18
View File
@@ -10,6 +10,7 @@ from common.criteria import (
ChoiceCriterion,
IntCriterion,
Modifier,
MultiCriterion,
StringCriterion,
)
from common.components import FilterBar
@@ -98,6 +99,38 @@ class TestChoiceCriterion:
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:
"""Verify ChoiceCriterion produces correct DB results."""
@@ -235,20 +268,20 @@ class TestGameFilterToQ:
class TestFilterBarRendering:
"""Tests for FilterBar with SelectableFilter widgets."""
"""Tests for FilterBar with FilterSelect widgets."""
def test_status_uses_selectable_filter(self):
html = str(FilterBar(platform_options=[]))
assert "data-selectable-filter" in html
def test_status_uses_filter_select(self):
html = str(FilterBar())
assert 'data-search-select-mode="filter"' in html
assert 'data-name="status"' in html
def test_mastered_not_checked_by_default(self):
html = str(FilterBar(filter_json="", platform_options=[]))
html = str(FilterBar(filter_json=""))
assert 'checked="true"' not in html
def test_mastered_checked_when_filtered(self):
html = str(
FilterBar(
platform_options=[],
filter_json=json.dumps(
{"mastered": {"value": True, "modifier": "EQUALS"}}
),
@@ -259,9 +292,8 @@ class TestFilterBarRendering:
def test_status_prefilled(self):
html = str(
FilterBar(
platform_options=[],
filter_json=json.dumps(
{"status": {"value": ["f"], "modifier": "INCLUDES"}}
{"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}}
),
)
)
@@ -269,19 +301,19 @@ class TestFilterBarRendering:
assert "Finished" in html
def test_no_hx_get(self):
html = str(FilterBar(platform_options=[]))
html = str(FilterBar())
assert "hx-get" not in html
def test_platform_options_rendered(self):
html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")]))
assert "Steam" in html
assert "Switch" in html
def test_platform_uses_search_url(self):
"""Platform is model-backed: rows are fetched, not pre-rendered."""
html = str(FilterBar())
assert 'data-search-url="/api/platforms/search"' in html
def test_status_has_no_modifiers(self):
"""Non-nullable fields should not show (None) but MUST show (Any)."""
html = str(FilterBar(platform_options=[]))
status_start = html.find('data-selectable-filter="status"')
platform_start = html.find('data-selectable-filter="platform"')
html = str(FilterBar())
status_start = html.find('data-name="status"')
platform_start = html.find('data-name="platform"')
status_section = html[status_start:platform_start]
# Must have (Any) — always available
assert "(Any)" in status_section
@@ -290,8 +322,8 @@ class TestFilterBarRendering:
def test_platform_has_modifiers(self):
"""Nullable ForeignKey fields should show (Any)/(None)."""
html = str(FilterBar(platform_options=[(1, "Steam")]))
platform_start = html.find('data-selectable-filter="platform"')
html = str(FilterBar())
platform_start = html.find('data-name="platform"')
platform_section = html[platform_start:]
# Should have at least one modifier option
assert "(Any)" in platform_section or "(None)" in platform_section
+121 -6
View File
@@ -7,6 +7,7 @@ import django.test
from django.utils.safestring import SafeText
from common.components import (
FilterSelect,
Pill,
SearchSelect,
searchselect_selected,
@@ -51,7 +52,7 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_empty_options_renders_no_results_scaffold(self):
html = SearchSelect(name="games")
self.assertIn("data-ss-no-results", html)
self.assertIn("data-search-select-no-results", html)
self.assertIn("No results", html)
def test_outer_container_carries_config(self):
@@ -70,7 +71,7 @@ class SearchSelectComponentTest(unittest.TestCase):
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
self.assertIn("data-pill", html)
self.assertIn('<input type="hidden" name="games" value="7">', html)
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('data-platform="2"', html)
# exactly one submitted value (the hidden input) — the search box has no
# name. The leading space avoids matching the container's data-name.
@@ -85,18 +86,18 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html)
# the value is still submitted via a lone hidden input
self.assertIn('<input type="hidden" name="games" value="7">', html)
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self):
html = SearchSelect(name="games")
self.assertIn("data-ss-search", html)
self.assertIn("data-search-select-search", html)
# container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self):
html = SearchSelect(name="t", options=[("1", "One")])
self.assertIn('data-ss-option=""', html)
self.assertIn('data-search-select-option=""', html)
self.assertIn('data-value="1"', html)
self.assertIn("One", html)
@@ -104,7 +105,121 @@ class SearchSelectComponentTest(unittest.TestCase):
html = SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search"
)
self.assertNotIn('data-ss-option=""', html)
# No pre-rendered rows in the live panel; the row prototype lives only in
# 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):
Generated
+67 -48
View File
@@ -6,10 +6,6 @@ resolution-markers = [
"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]]
name = "annotated-types"
version = "0.7.0"
@@ -153,25 +149,25 @@ wheels = [
[[package]]
name = "distlib"
version = "0.4.0"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
name = "django"
version = "6.0.5"
version = "6.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -273,7 +269,7 @@ wheels = [
[[package]]
name = "djlint"
version = "1.36.4"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -286,13 +282,36 @@ dependencies = [
{ name = "regex" },
{ name = "tqdm" },
]
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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]]
@@ -306,11 +325,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.29.0"
version = "3.29.1"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -345,11 +364,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.17"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -821,27 +840,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.15"
version = "0.15.16"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
]
[[package]]
@@ -927,14 +946,14 @@ dev = [
[[package]]
name = "tqdm"
version = "4.67.3"
version = "4.68.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -991,7 +1010,7 @@ wheels = [
[[package]]
name = "virtualenv"
version = "21.4.1"
version = "21.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
@@ -999,7 +1018,7 @@ dependencies = [
{ name = "platformdirs" },
{ name = "python-discovery" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]