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
19 changed files with 1159 additions and 995 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. - **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()` - **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()` - **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips) - **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js` - **`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. **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` - **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- **Custom JS** in `games/static/js/`: - **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event) - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
- `selectable_filter.js` — SelectableFilter widget interaction - `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`) - `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment ### Deployment
@@ -159,10 +158,13 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
## Conventions for AI assistants ## Conventions for AI assistants
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database. - **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`. - **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped. - **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`. - **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls. - **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`. - **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
+6 -2
View File
@@ -36,10 +36,13 @@ from common.components.primitives import (
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
Template,
YearPicker, YearPicker,
paginated_table_content, paginated_table_content,
) )
from common.components.search_select import ( from common.components.search_select import (
FilterSelect,
LabeledOption,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
searchselect_selected, searchselect_selected,
@@ -58,7 +61,6 @@ from common.components.domain import (
from common.components.filters import ( from common.components.filters import (
FilterBar, FilterBar,
PurchaseFilterBar, PurchaseFilterBar,
SelectableFilter,
SessionFilterBar, SessionFilterBar,
) )
@@ -85,6 +87,8 @@ __all__ = [
"Popover", "Popover",
"PopoverTruncated", "PopoverTruncated",
"SearchField", "SearchField",
"FilterSelect",
"LabeledOption",
"SearchSelect", "SearchSelect",
"SearchSelectOption", "SearchSelectOption",
"searchselect_selected", "searchselect_selected",
@@ -94,6 +98,7 @@ __all__ = [
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
"Template",
"YearPicker", "YearPicker",
"paginated_table_content", "paginated_table_content",
"GameLink", "GameLink",
@@ -107,6 +112,5 @@ __all__ = [
"_resolve_name_with_icon", "_resolve_name_with_icon",
"FilterBar", "FilterBar",
"PurchaseFilterBar", "PurchaseFilterBar",
"SelectableFilter",
"SessionFilterBar", "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 typing import NamedTuple
from django.db import models from django.db import models
from django.utils.html import escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component from common.components.core import Component
from common.components.primitives import Label, Span from common.components.primitives import Label, Span
from common.components.search_select import FilterSelect, LabeledOption
class FilterChoice(NamedTuple): class FilterChoice(NamedTuple):
"""Parsed 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] ``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For
excluded: list[str] 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 modifier: str
class RangeValues(NamedTuple):
"""A (min, max) string pair parsed from a range filter criterion."""
min: str
max: str
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide" _FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
_FILTER_INPUT_CLASS = (
"block w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-2 "
"focus:ring-brand focus:border-brand"
)
_FILTER_CHECKBOX_CLASS = ( _FILTER_CHECKBOX_CLASS = (
"rounded border-default-medium bg-neutral-secondary-medium " "rounded border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand" "text-brand focus:ring-brand"
@@ -49,30 +54,28 @@ def _filter_parse(filter_json: str) -> dict:
return {} return {}
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
return [(str(item["id"]), str(item["label"])) for item in items]
def _filter_get_choice(existing: dict, field: str) -> FilterChoice: def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
raw = existing.get(field, {}) raw = existing.get(field, {})
if not isinstance(raw, dict): if not isinstance(raw, dict):
return FilterChoice([], [], "") return FilterChoice([], [], "")
value = raw.get("value", [])
excluded = raw.get("excludes", [])
modifier = raw.get("modifier", "")
if isinstance(value, str):
value = [value]
if isinstance(excluded, str):
excluded = [excluded]
return FilterChoice( return FilterChoice(
selected=[str(v) for v in (value or [])], selected=_extract_labeled(raw.get("value") or []),
excluded=[str(v) for v in (excluded or [])], excluded=_extract_labeled(raw.get("excludes") or []),
modifier=modifier or "", modifier=raw.get("modifier") or "",
) )
def _parse_range(existing: dict, key: str) -> tuple[str, str]: def _parse_range(existing: dict, key: str) -> RangeValues:
"""Extract (value, value2) from a filter criterion, defaulting to ("", "").""" """Extract (min, max) from a range filter criterion, defaulting to ("", "")."""
field = existing.get(key, {}) field = existing.get(key, {})
if not isinstance(field, dict): if not isinstance(field, dict):
return "", "" return RangeValues("", "")
return str(field.get("value", "")), str(field.get("value2", "")) return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
def _parse_bool(existing: dict, key: str) -> bool: def _parse_bool(existing: dict, key: str) -> bool:
@@ -83,20 +86,58 @@ def _parse_bool(existing: dict, key: str) -> bool:
return bool(field.get("value", False)) return bool(field.get("value", False))
def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]: # ── FilterSelect adapters ────────────────────────────────────────────────────
"""Return (value, label) pairs for a SelectableFilter from model rows. # 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), _FILTER_PREFETCH = 20
but unpacks each row into readable local variables.
"""
options: list[tuple[str, str]] = [] def _modifier_options(nullable: bool) -> list[LabeledOption]:
for object_id, object_name in model_class.objects.order_by(order_by).values_list( """Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
"id", order_by options = [("NOT_NULL", "(Any)")]
): if nullable:
options.append((str(object_id), object_name)) options.append(("IS_NULL", "(None)"))
return options 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: def _filter_mins_to_hrs(val) -> str:
if val is None or val == "" or val == 0: if val is None or val == "" or val == 0:
return "" 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: def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
return Label( return Label(
attributes=[("class", "flex items-center gap-2 text-sm text-heading")], attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
@@ -347,8 +371,7 @@ def RangeSlider(
("data-target", min_input_id), ("data-target", min_input_id),
( (
"style", "style",
"left:0" "left:0" + (";display:none" if point_mode else ""),
+ (";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( def FilterBar(
filter_json: str = "", filter_json: str = "",
status_options: list[tuple[str, str]] | None = None, status_options: list[LabeledOption] | None = None,
platform_options: list[tuple[int, str]] | None = None,
preset_list_url: str = "", preset_list_url: str = "",
preset_save_url: str = "", preset_save_url: str = "",
) -> SafeText: ) -> SafeText:
"""Collapsible filter bar for the Game list.""" """Collapsible filter bar for the Game list."""
from games.models import Game, Platform from games.models import Game
if status_options is None: if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status] status_options = [(s.value, s.label) for s in Game.Status]
if platform_options is None:
platform_options = _get_filter_options(Platform)
existing = _filter_parse(filter_json) existing = _filter_parse(filter_json)
status_choice = _filter_get_choice(existing, "status") status_choice = _filter_get_choice(existing, "status")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
platform_options_str = [(str(pk), name) for pk, name in platform_options]
year_min, year_max = _parse_range(existing, "year_released") year_min, year_max = _parse_range(existing, "year_released")
mastered_value = _parse_bool(existing, "mastered") mastered_value = _parse_bool(existing, "mastered")
@@ -617,23 +636,19 @@ def FilterBar(
children=[ children=[
_filter_field( _filter_field(
"Status", "Status",
SelectableFilter( _enum_filter(
"status", "status",
status_options, status_options,
status_choice.selected, status_choice,
status_choice.excluded,
status_choice.modifier,
nullable=not Game._meta.get_field("status").has_default(), nullable=not Game._meta.get_field("status").has_default(),
), ),
), ),
_filter_field( _filter_field(
"Platform", "Platform",
SelectableFilter( _model_filter(
"platform", "platform",
platform_options_str, platform_choice,
platform_choice.selected, search_url="/api/platforms/search",
platform_choice.excluded,
platform_choice.modifier,
nullable=Game._meta.get_field("platform").null, 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) return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def _selectable_filter_tag( def _find_label(options: list[LabeledOption], value: str) -> str:
value: str, label: str, *, excluded: bool = False
) -> SafeText:
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
checkmark = "\u2717" if excluded else "\u2713"
css = "sf-tag sf-excluded" if excluded else "sf-tag"
return Span(
attributes=[
("class", css),
("data-value", value),
("data-type", "exclude" if excluded else "include"),
],
children=[
Span(
attributes=[("class", "sf-tag-text")],
children=[f"{checkmark} {label}"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-remove"),
("aria-label", "Remove"),
],
children=["\u00d7"],
),
],
)
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
return Span(
attributes=[
("class", "sf-modifier-tag active"),
("data-modifier", modifier),
],
children=[label],
)
def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
"""A modifier choice in the SelectableFilter dropdown list."""
return Component(
tag_name="div",
attributes=[
("class", "sf-option sf-modifier-option"),
("data-modifier", modifier),
("data-label", label),
],
children=[
Span(
attributes=[("class", "sf-option-label")],
children=[label],
),
],
)
def _selectable_filter_option(value: str, label: str) -> SafeText:
"""An option row with include (+) and exclude (\u2212) buttons."""
return Component(
tag_name="div",
attributes=[
("class", "sf-option"),
("data-value", value),
("data-label", label),
],
children=[
Span(
attributes=[("class", "sf-option-label")],
children=[label],
),
Span(
attributes=[("class", "sf-option-buttons")],
children=[
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-btn-include"),
("data-action", "include"),
("title", "Include"),
],
children=["+"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-btn-exclude"),
("data-action", "exclude"),
("title", "Exclude"),
],
children=["\u2212"],
),
],
),
],
)
def SelectableFilter(
field_name: str,
options: list[tuple[str, str]],
selected: list[str] | None = None,
excluded: list[str] | None = None,
modifier: str = "",
nullable: bool = True,
) -> "SafeText":
"""Stash-style selectable filter with search, include/exclude, modifier tags."""
selected = selected or []
excluded = excluded or []
modifier_options = [("NOT_NULL", "(Any)")]
if nullable:
modifier_options.append(("IS_NULL", "(None)"))
active_modifier_tag = ""
inactive_modifier_options: list[SafeText] = []
for modifier_value, modifier_label in modifier_options:
if modifier == modifier_value:
active_modifier_tag = _selectable_filter_modifier_tag(
modifier_value, modifier_label
)
else:
inactive_modifier_options.append(
_selectable_filter_modifier_option(modifier_value, modifier_label)
)
selected_tags: list[SafeText] = []
for value in selected:
selected_tags.append(
_selectable_filter_tag(value, _find_label(options, value), excluded=False)
)
for value in excluded:
selected_tags.append(
_selectable_filter_tag(value, _find_label(options, value), excluded=True)
)
option_rows: list[SafeText] = []
for value, label in options:
option_rows.append(_selectable_filter_option(value, label))
selected_area_children: list[SafeText] = []
if active_modifier_tag:
selected_area_children.append(active_modifier_tag)
selected_area_children.extend(selected_tags)
options_area_children: list[SafeText] = []
options_area_children.extend(inactive_modifier_options)
options_area_children.extend(option_rows)
return Component(
tag_name="div",
attributes=[
("class", "sf-container"),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
Component(
tag_name="div",
attributes=[("class", "sf-selected")],
children=selected_area_children,
),
Component(
tag_name="input",
attributes=[
("type", "text"),
("class", "sf-search"),
("placeholder", "Search\u2026"),
],
),
Component(
tag_name="div",
attributes=[("class", "sf-options")],
children=options_area_children,
),
],
)
def _find_label(options: list[tuple[str, str]], value: str) -> str:
for v, label in options: for v, label in options:
if str(v) == str(value): if str(v) == str(value):
return label return label
@@ -865,10 +697,8 @@ def SessionFilterBar(
filter_json="", preset_list_url="", preset_save_url="" filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText: ) -> SafeText:
"""Collapsible filter bar for the Session list.""" """Collapsible filter bar for the Session list."""
from games.models import 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) existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game") game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device") device_choice = _filter_get_choice(existing, "device")
@@ -898,23 +728,19 @@ def SessionFilterBar(
children=[ children=[
_filter_field( _filter_field(
"Game", "Game",
SelectableFilter( _model_filter(
"game", "game",
game_options, game_choice,
game_choice.selected, search_url="/api/games/search",
game_choice.excluded,
game_choice.modifier,
nullable=not Game._meta.get_field("name").has_default(), nullable=not Game._meta.get_field("name").has_default(),
), ),
), ),
_filter_field( _filter_field(
"Device", "Device",
SelectableFilter( _model_filter(
"device", "device",
device_options, device_choice,
device_choice.selected, search_url="/api/devices/search",
device_choice.excluded,
device_choice.modifier,
nullable=Session._meta.get_field("device").null, nullable=Session._meta.get_field("device").null,
), ),
), ),
@@ -946,12 +772,10 @@ def PurchaseFilterBar(
filter_json="", preset_list_url="", preset_save_url="" filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText: ) -> SafeText:
"""Collapsible filter bar for the Purchase list.""" """Collapsible filter bar for the Purchase list."""
from games.models import Game, Platform, Purchase from games.models import Purchase
game_options = _get_filter_options(Game) type_options = Purchase.TYPES
platform_options = _get_filter_options(Platform) ownership_options = Purchase.OWNERSHIP_TYPES
type_options = [(value, label) for value, label in Purchase.TYPES]
ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES]
existing = _filter_parse(filter_json) existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "games") game_choice = _filter_get_choice(existing, "games")
platform_choice = _filter_get_choice(existing, "platform") platform_choice = _filter_get_choice(existing, "platform")
@@ -975,45 +799,37 @@ def PurchaseFilterBar(
children=[ children=[
_filter_field( _filter_field(
"Game", "Game",
SelectableFilter( _model_filter(
"games", "games",
game_options, game_choice,
game_choice.selected, search_url="/api/games/search",
game_choice.excluded,
game_choice.modifier,
nullable=False, nullable=False,
), ),
), ),
_filter_field( _filter_field(
"Platform", "Platform",
SelectableFilter( _model_filter(
"platform", "platform",
platform_options, platform_choice,
platform_choice.selected, search_url="/api/platforms/search",
platform_choice.excluded,
platform_choice.modifier,
nullable=Purchase._meta.get_field("platform").null, nullable=Purchase._meta.get_field("platform").null,
), ),
), ),
_filter_field( _filter_field(
"Type", "Type",
SelectableFilter( _enum_filter(
"type", "type",
type_options, type_options,
type_choice.selected, type_choice,
type_choice.excluded,
type_choice.modifier,
nullable=not Purchase._meta.get_field("type").has_default(), nullable=not Purchase._meta.get_field("type").has_default(),
), ),
), ),
_filter_field( _filter_field(
"Ownership", "Ownership",
SelectableFilter( _enum_filter(
"ownership_type", "ownership_type",
ownership_options, ownership_options,
ownership_choice.selected, ownership_choice,
ownership_choice.excluded,
ownership_choice.modifier,
nullable=not Purchase._meta.get_field( nullable=not Purchase._meta.get_field(
"ownership_type" "ownership_type"
).has_default(), ).has_default(),
+22 -2
View File
@@ -369,6 +369,16 @@ def Label(
return Component(tag_name="label", attributes=attributes, children=children) return Component(tag_name="label", attributes=attributes, children=children)
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
attributes = attributes or []
children = children or []
return Component(tag_name="template", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in # Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
# input.css, written inline so styling stays encapsulated in the component). The # input.css, written inline so styling stays encapsulated in the component). The
# JS that builds pills client-side (search_select.js) MUST emit these exact class # JS that builds pills client-side (search_select.js) MUST emit these exact class
@@ -386,6 +396,7 @@ def Pill(
value: str = "", value: str = "",
removable: bool = False, removable: bool = False,
extra_class: str = "", extra_class: str = "",
label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
) -> SafeText: ) -> SafeText:
"""A small label pill, optionally removable (× button). """A small label pill, optionally removable (× button).
@@ -393,6 +404,10 @@ def Pill(
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove`` Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
are JS hooks only (no CSS attached). ``value`` (when set) becomes are JS hooks only (no CSS attached). ``value`` (when set) becomes
``data-value``; extra ``attributes`` are appended to the outer span. ``data-value``; extra ``attributes`` are appended to the outer span.
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``).
""" """
attributes = attributes or [] attributes = attributes or []
pill_class = f"{_PILL_CLASS} {extra_class}".strip() pill_class = f"{_PILL_CLASS} {extra_class}".strip()
@@ -401,7 +416,12 @@ def Pill(
pill_attrs.append(("data-value", str(value))) pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes) 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: if removable:
children.append( children.append(
Component( 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: 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 This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are ``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``data-*`` attributes wired up by ``games/static/js/search_select.js``. ``data-*`` attributes wired up by ``games/static/js/search_select.js``.
Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
filtering is purely client-side; with a ``search_url`` the loaded rows are a
window, so the JS filters the loaded rows instantly on each keystroke while
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
first open, ``0`` = none) seeds that window so the panel is populated before the
user types.
""" """
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
@@ -15,7 +24,7 @@ from typing import TypedDict
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute from common.components.core import Component, HTMLAttribute
from common.components.primitives import Pill from common.components.primitives import Div, Input, Pill, Span, Template
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
@@ -24,20 +33,32 @@ class SearchSelectOption(TypedDict):
data: dict[str, str] # becomes data-* attrs on the row / pill data: dict[str, str] # becomes data-* attrs on the row / pill
# removed border and border-default-medium, see later if it's needed # A lightweight (value, label) pair used wherever only those two fields are
_CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium" # needed — e.g. filter pill lists and modifier pseudo-options. The richer
# The pills and the search box share one flex-wrap row so the widget reads as a # SearchSelectOption adds a ``data`` dict for extra row attributes.
# single field; the pills wrapper uses `contents` so its pills/hidden inputs LabeledOption = tuple[str, str]
# flow as direct participants of that row, inline with the search input.
_FIELD_CLASS = "flex flex-wrap items-center gap-1 p-2"
# The pills and the search box share one flex-wrap row (with padding) so the
# widget reads as a single clickable field; the pills wrapper uses `contents`
# so its pills/hidden inputs flow as direct participants of that row, inline
# with the search input. The options panel is absolute, so it sits outside the
# flex flow. (border omitted intentionally — see if it's needed later.)
_CONTAINER_CLASS = (
"relative flex flex-wrap items-center gap-1 p-2 "
"rounded-base bg-neutral-secondary-medium"
)
_PILLS_CLASS = "contents" _PILLS_CLASS = "contents"
_SEARCH_CLASS = ( _SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
"focus:ring-0 focus:outline-hidden placeholder:text-body" "focus:ring-0 focus:outline-hidden placeholder:text-body"
) )
# top-full anchors the panel to the container's bottom edge: as an absolutely
# positioned child of the flex field, its static position would otherwise be
# centered by items-center and overlap the search box.
_OPTIONS_CLASS = ( _OPTIONS_CLASS = (
"absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium " "absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
"rounded-base bg-neutral-secondary-medium shadow-lg" "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" _OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden" _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
@@ -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. # used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25 _ROW_HEIGHT_REM = 2.25
# ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
# rows/pills are cloned from server-rendered <template>s, so these strings live
# only here — never duplicated in search_select.js.
_FILTER_INCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-brand/15 text-heading"
)
_FILTER_EXCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-red-500/15 text-red-600 line-through decoration-red-400"
)
_FILTER_MODIFIER_PILL_CLASS = (
"inline-flex items-center px-2 py-0.5 text-sm rounded "
"bg-amber-500/15 text-amber-600 cursor-pointer"
)
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
_FILTER_OPTION_ROW_CLASS = (
"flex items-center justify-between px-2 py-1 rounded text-sm "
"hover:bg-neutral-secondary-strong cursor-pointer"
)
_FILTER_OPTION_LABEL_CLASS = "truncate text-body"
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
# text-body keeps the +/ readable on dark backgrounds; hover:border-brand-strong
# keeps the edge visible against the brand hover fill.
_FILTER_ACTION_BUTTON_CLASS = (
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
"border border-brand "
"hover:bg-brand hover:text-white hover:border-brand-strong"
)
_FILTER_MODIFIER_ROW_CLASS = (
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
)
def _normalize_option(option) -> SearchSelectOption: def _normalize_option(option) -> SearchSelectOption:
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict.""" """Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
@@ -64,23 +120,80 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
def _hidden_input(name: str, value) -> SafeText: def _hidden_input(name: str, value) -> SafeText:
return Component( return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
tag_name="input",
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
) def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side."""
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
if extra_class:
attributes.append(("class", extra_class))
return Span(attributes=attributes, children=[text])
# A placeholder option for rendering template prototypes (JS overwrites it).
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> SafeText: def _option_row(option: SearchSelectOption) -> SafeText:
return Component( return Div(
tag_name="div",
attributes=[ attributes=[
("data-ss-option", ""), ("data-search-select-option", ""),
("data-value", str(option["value"])), ("data-value", str(option["value"])),
("data-label", option["label"]), ("data-label", option["label"]),
("class", _OPTION_ROW_CLASS), ("class", _OPTION_ROW_CLASS),
*_data_attributes(option["data"]), *_data_attributes(option["data"]),
], ],
children=[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, always_visible: bool = False,
items_visible: int = 5, items_visible: int = 5,
items_scroll: int = 10, items_scroll: int = 10,
prefetch: int = 0,
placeholder: str = "Search…", placeholder: str = "Search…",
id: str = "", id: str = "",
sync_url: bool = False, sync_url: bool = False,
autofocus: bool = False, autofocus: bool = False,
) -> SafeText: ) -> SafeText:
"""Render the search-select widget. See module docstring for the contract.""" """Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(o) for o in (selected or [])] selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(o) for o in (options or [])] options = [_normalize_option(option) for option in (options or [])]
# ── Pills + their hidden inputs (the submitted channel) ── # ── Pills + their hidden inputs (the submitted channel) ──
# Multi-select renders a removable Pill per value; single-select renders no # Multi-select renders a removable Pill per value; single-select renders no
# pill — the committed label shows inside the search box instead, with a # pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside # lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-ss-pills]` so the JS reads/writes values uniformly. # `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = [] pills_children: list[SafeText] = []
search_value = "" search_value = ""
if multi_select: if multi_select:
@@ -117,6 +231,7 @@ def SearchSelect(
option["label"], option["label"],
value=str(option["value"]), value=str(option["value"]),
removable=True, removable=True,
label_slot=True,
attributes=_data_attributes(option["data"]), attributes=_data_attributes(option["data"]),
) )
) )
@@ -126,16 +241,14 @@ def SearchSelect(
pills_children.append(_hidden_input(name, option["value"])) pills_children.append(_hidden_input(name, option["value"]))
search_value = option["label"] search_value = option["label"]
pills = Component( pills = Div(
tag_name="div", attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children, children=pills_children,
) )
# ── Search box (NO name — the query is never submitted) ── # ── Search box (NO name — the query is never submitted) ──
search_attrs: list[HTMLAttribute] = [ search_attrs: list[HTMLAttribute] = [
("data-ss-search", ""), ("data-search-select-search", ""),
("type", "text"),
("placeholder", placeholder), ("placeholder", placeholder),
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
@@ -144,27 +257,29 @@ def SearchSelect(
search_attrs.append(("autofocus", "")) search_attrs.append(("autofocus", ""))
if search_value: if search_value:
search_attrs.append(("value", search_value)) search_attrs.append(("value", search_value))
search = Component(tag_name="input", attributes=search_attrs)
# ── Options panel (pre-rendered only when there is no search_url) ── # ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(o) for o in options] if not search_url else [] option_rows = [_option_row(option) for option in options] if not search_url else []
no_results = Component(
tag_name="div", # ── Templates the JS clones: a row when results are fetched, a pill when
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)], # multi-select adds chosen items. ──
children=["No results"], 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-search-select", ""),
("data-name", name), ("data-name", name),
("data-search-url", search_url), ("data-search-url", search_url),
@@ -172,16 +287,242 @@ def SearchSelect(
("data-always-visible", "true" if always_visible else "false"), ("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)), ("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)), ("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"), ("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS), ("class", _CONTAINER_CLASS),
] ]
if id: if id:
container_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( return Component(
tag_name="div", tag_name="button",
attributes=container_attrs, attributes=[
children=[pills, search, options_panel], ("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: if not values:
return [] 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 @dataclass
class MultiCriterion(_Criterion): class _SetCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list.""" """Shared base for set-membership criteria (``MultiCriterion`` /
``ChoiceCriterion``).
value: list[int] = field(default_factory=list) ``value`` is the include set and ``excludes`` the exclude set. The common
excludes: list[int] = field(default_factory=list) modifiers are implemented once here so the two subclasses cannot drift:
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q: - ``INCLUDES`` — in ``value`` (when non-empty) AND not in ``excludes`` (when
m = self.modifier non-empty). Empty lists contribute no constraint, so an exclude-only
if m == Modifier.INCLUDES: criterion means "everything except ``excludes``".
q = Q(**{f"{field_name}__in": self.value}) - ``EQUALS`` — alias of ``INCLUDES``.
if self.excludes: - ``IS_NULL`` / ``NOT_NULL`` — presence; the lists are ignored.
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")
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by
@dataclass overriding ``_extra_q``.
class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by SelectableFilter widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
""" """
value: list[str] = field(default_factory=list) value: list = field(default_factory=list)
excludes: list[str] = field(default_factory=list) excludes: list = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q: def to_q(self, field_name: str) -> Q:
m = self.modifier modifier = self.modifier
if m == Modifier.INCLUDES: if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
q = Q() q = Q()
if self.value: if self.value:
q &= Q(**{f"{field_name}__in": self.value}) q &= Q(**{f"{field_name}__in": self.value})
if self.excludes: if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes}) q &= ~Q(**{f"{field_name}__in": self.excludes})
return q return q
if 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() q = Q()
if self.value: if self.value:
q &= ~Q(**{f"{field_name}__in": self.value}) q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes: if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes}) q &= Q(**{f"{field_name}__in": self.excludes})
return q return q
if m == Modifier.EQUALS: if self.modifier == Modifier.NOT_EQUALS:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value}) return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.IS_NULL: return None
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ──────────────────────────────────────────────────── # ── OperatorFilter base ────────────────────────────────────────────────────
-45
View File
@@ -232,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 { .static {
position: static; position: static;
} }
.sticky {
position: sticky;
}
.inset-0 { .inset-0 {
inset: calc(var(--spacing) * 0); inset: calc(var(--spacing) * 0);
} }
@@ -1285,6 +1288,9 @@
.ml-1 { .ml-1 {
margin-left: calc(var(--spacing) * 1); margin-left: calc(var(--spacing) * 1);
} }
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
@@ -2074,6 +2080,12 @@
.bg-amber-50 { .bg-amber-50 {
background-color: var(--color-amber-50); background-color: var(--color-amber-50);
} }
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black\/70 { .bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent); background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2179,6 +2191,12 @@
.bg-red-500 { .bg-red-500 {
background-color: var(--color-red-500); background-color: var(--color-red-500);
} }
.bg-red-500\/15 {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
}
.bg-red-600 { .bg-red-600 {
background-color: var(--color-red-600); background-color: var(--color-red-600);
} }
@@ -2562,6 +2580,9 @@
.text-amber-500 { .text-amber-500 {
color: var(--color-amber-500); color: var(--color-amber-500);
} }
.text-amber-600 {
color: var(--color-amber-600);
}
.text-amber-800 { .text-amber-800 {
color: var(--color-amber-800); color: var(--color-amber-800);
} }
@@ -2658,12 +2679,18 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.line-through {
text-decoration-line: line-through;
}
.no-underline\! { .no-underline\! {
text-decoration-line: none !important; text-decoration-line: none !important;
} }
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }
.decoration-red-400 {
text-decoration-color: var(--color-red-400);
}
.decoration-slate-500 { .decoration-slate-500 {
text-decoration-color: var(--color-slate-500); text-decoration-color: var(--color-slate-500);
} }
@@ -2913,6 +2940,13 @@
} }
} }
} }
.hover\:border-brand-strong {
&:hover {
@media (hover: hover) {
border-color: var(--color-brand-strong);
}
}
}
.hover\:border-default { .hover\:border-default {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -2934,6 +2968,13 @@
} }
} }
} }
.hover\:bg-brand {
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
}
.hover\:bg-brand-strong { .hover\:bg-brand-strong {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -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\:bg-neutral-tertiary-medium {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -4381,171 +4429,6 @@ form input:disabled, select:disabled, textarea:disabled {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
} }
.sf-container {
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
}
.sf-selected {
display: flex;
min-height: 2rem;
flex-wrap: wrap;
gap: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 2);
}
.sf-tag {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1);
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
}
.sf-tag.sf-excluded {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
color: var(--color-red-600);
text-decoration-line: line-through;
text-decoration-color: var(--color-red-400);
}
.sf-remove {
margin-left: calc(var(--spacing) * 1);
cursor: pointer;
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-body);
&:hover {
@media (hover: hover) {
color: var(--color-heading);
}
}
}
.sf-modifier-tag {
display: inline-flex;
cursor: pointer;
align-items: center;
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-amber-600);
}
.sf-search {
display: block;
width: 100%;
border-style: var(--tw-border-style);
border-width: 0px;
border-top-style: var(--tw-border-style);
border-top-width: 1px;
border-color: var(--color-default-medium);
background-color: transparent;
padding: calc(var(--spacing) * 2);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
.sf-options {
max-height: calc(var(--spacing) * 40);
overflow-y: auto;
padding: calc(var(--spacing) * 1);
color: var(--color-body);
}
.sf-option {
display: flex;
cursor: pointer;
align-items: center;
justify-content: space-between;
border-radius: var(--radius);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.sf-option-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-option-buttons {
margin-left: calc(var(--spacing) * 2);
display: flex;
flex-shrink: 0;
gap: calc(var(--spacing) * 1);
}
.sf-btn-include, .sf-btn-exclude {
display: flex;
height: calc(var(--spacing) * 5);
width: calc(var(--spacing) * 5);
align-items: center;
justify-content: center;
border-radius: var(--radius);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
&:hover {
@media (hover: hover) {
border-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-white);
}
}
}
.sf-modifier-option {
cursor: pointer;
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-body);
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
@layer base { @layer base {
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select { input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
appearance: none; appearance: none;
+19 -50
View File
@@ -59,62 +59,31 @@
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" }; filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
} }
// ── Generic SelectableFilter widgets ── // ── FilterSelect widgets (data-search-select-mode="filter") ──
readSelectableFilters(form); // readSearchSelect serialises each into data-included/data-excluded/data-modifier.
var widgets = form.querySelectorAll("[data-selectable-filter]"); readSearchSelect(form);
widgets.forEach(function (w) { var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
var field = w.getAttribute("data-selectable-filter"); widgets.forEach(function (widget) {
var inc = parseJSONAttr(w, "data-included"); var field = widget.getAttribute("data-name");
var exc = parseJSONAttr(w, "data-excluded"); var included = parseJSONAttr(widget, "data-included");
var mod = w.getAttribute("data-modifier"); var excluded = parseJSONAttr(widget, "data-excluded");
if (mod === "NOT_NULL" || mod === "IS_NULL") { var modifier = widget.getAttribute("data-modifier");
filter[field] = { modifier: mod }; if (modifier === "NOT_NULL" || modifier === "IS_NULL") {
} else if (inc.length > 0 || exc.length > 0) { filter[field] = { modifier: modifier };
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games"; } 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] = { filter[field] = {
value: isIdField ? inc.map(Number) : inc, value: included.map(function (item) { return {id: item.id, label: item.label}; }),
excludes: isIdField ? exc.map(Number) : exc, excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
modifier: mod || "INCLUDES", modifier: modifier || "INCLUDES",
}; };
} }
}); });
// ── Session-specific fields ── // ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]'); var pageIsSessions =
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// Emulated checkbox (sessions page) // Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]'); var emulated = form.querySelector('[name="filter-emulated"]');
+242 -96
View File
@@ -6,49 +6,53 @@
* focus clears it to search, picking an option fills it), with a lone hidden * focus clears it to search, picking an option fills it), with a lone hidden
* <input> carrying the value. Both keep hidden inputs so Django validation works. * <input> carrying the value. Both keep hidden inputs so Django validation works.
* *
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap, * Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
* each widget guarded with el._ssInit. * 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 * initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
* Pill / SearchSelect components so Tailwind generates the classes and * element._searchSelectInit.
* server-rendered and JS-created pills are indistinguishable. *
* Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect /
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
* and data-* attributes — so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here.
*/ */
(function () { (function () {
"use strict"; "use strict";
var PILL_CLASS =
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
"bg-brand/15 text-heading";
var PILL_REMOVE_CLASS =
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
var OPTION_ROW_CLASS =
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
var DEBOUNCE_MS = 500; var DEBOUNCE_MS = 500;
function initAll() { function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (el) { document.querySelectorAll("[data-search-select]").forEach(function (element) {
if (el._ssInit) return; if (element._searchSelectInit) return;
el._ssInit = true; element._searchSelectInit = true;
initWidget(el); initWidget(element);
}); });
} }
function initWidget(container) { function initWidget(container) {
var search = container.querySelector("[data-ss-search]"); var search = container.querySelector("[data-search-select-search]");
var options = container.querySelector("[data-ss-options]"); var options = container.querySelector("[data-search-select-options]");
var pills = container.querySelector("[data-ss-pills]"); var pills = container.querySelector("[data-search-select-pills]");
if (!search || !options || !pills) return; if (!search || !options || !pills) return;
var name = container.getAttribute("data-name"); var name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url"); var searchUrl = container.getAttribute("data-search-url");
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
var multi = container.getAttribute("data-multi") === "true"; var multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true"; var alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10; var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
var syncUrl = container.getAttribute("data-sync-url") === "true"; var syncUrl = container.getAttribute("data-sync-url") === "true";
var noResults = options.querySelector("[data-ss-no-results]"); var noResults = options.querySelector("[data-search-select-no-results]");
var debounceTimer = null; var debounceTimer = null;
var pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false;
function showPanel() { function showPanel() {
options.classList.remove("hidden"); options.classList.remove("hidden");
@@ -63,115 +67,229 @@
// ── Render server-fetched rows into the panel ── // ── Render server-fetched rows into the panel ──
function renderRows(items) { function renderRows(items) {
options.querySelectorAll("[data-ss-option]").forEach(function (r) { options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
r.remove(); row.remove();
}); });
items.slice(0, itemsScroll).forEach(function (item) { items.slice(0, itemsScroll).forEach(function (item) {
options.insertBefore(buildRow(item), noResults || null); options.insertBefore(buildRow(item), noResults || null);
}); });
setNoResults(items.length === 0);
showPanel(); showPanel();
} }
// ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
function cloneTemplate(name) {
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
return template
? template.content.firstElementChild.cloneNode(true)
: null;
}
function setLabel(node, label) {
var slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label;
}
function applyData(node, data) {
data = data || {};
Object.keys(data).forEach(function (key) {
node.setAttribute("data-" + key, data[key]);
});
}
// Build an option row by cloning the "row" template (the same prototype the
// server renders, so fetched and pre-rendered rows are identical).
function buildRow(option) { function buildRow(option) {
var row = document.createElement("div"); var row = cloneTemplate("row");
row.setAttribute("data-ss-option", ""); if (!row) return document.createComment("ss-row");
row.setAttribute("data-value", option.value); row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label); row.setAttribute("data-label", option.label);
row.className = OPTION_ROW_CLASS; applyData(row, option.data);
var data = option.data || {}; setLabel(row, option.label);
Object.keys(data).forEach(function (key) { row._searchSelectOption = option;
row.setAttribute("data-" + key, data[key]);
});
row.textContent = option.label;
row._ssOption = option;
return row; return row;
} }
// ── Client-side filter of pre-rendered rows ── // ── Client-side filter of the currently loaded rows. Returns the number of
function filterRows(q) { // visible rows so the caller decides whether to show the no-results node. ──
var lower = q.toLowerCase(); function filterRows(query) {
var anyVisible = false; var lower = query.toLowerCase();
options.querySelectorAll("[data-ss-option]").forEach(function (item) { var visibleCount = 0;
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase(); var label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1; var match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none"; item.style.display = match ? "" : "none";
if (match) anyVisible = true; if (match) visibleCount += 1;
}); });
setNoResults(!anyVisible); return visibleCount;
showPanel();
} }
function runSearch() { // ── Fetch matching rows from the server. The previous in-flight request is
var q = search.value.trim(); // aborted so a slower earlier response can never overwrite a newer one. ──
if (searchUrl && q) { function fetchFromServer(query) {
clearTimeout(debounceTimer); if (pendingRequest) pendingRequest.abort();
debounceTimer = setTimeout(function () { pendingRequest = new AbortController();
fetch(searchUrl + "?q=" + encodeURIComponent(q), { var url = searchUrl + "?q=" + encodeURIComponent(query);
credentials: "same-origin", if (prefetch && !query) url += "&limit=" + prefetch;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(function (response) {
return response.json();
}) })
.then(function (r) { .then(function (items) {
return r.json(); 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 (error) {
.catch(function () { if (error && error.name === "AbortError") return; // superseded
pendingRequest = null;
setNoResults(true); 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); }, DEBOUNCE_MS);
} else { } else {
filterRows(q); setNoResults(filterRows(query) === 0);
} }
} }
// ── Single-select combobox: the search box shows the committed label; // ── Single-select combobox: the search box shows the committed label;
// focusing clears it to search, blurring restores it (or deselects). ── // focusing clears it to search, blurring restores it (or deselects). ──
if (!multi) container._ssLabel = search.value; if (!multi) container._searchSelectLabel = search.value;
search.addEventListener("focus", function () { search.addEventListener("focus", function () {
if (!multi) { if (!multi) {
// Hide the committed label so the box becomes a fresh search field. // Hide the committed label so the box becomes a fresh search field.
search.value = ""; search.value = "";
container._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 () { search.addEventListener("input", function () {
if (!multi) container._ssDirty = true; if (!multi) container._searchSelectDirty = true;
runSearch(); runSearch();
}); });
if (!multi) { if (!multi) {
search.addEventListener("blur", function () { search.addEventListener("blur", function () {
// Defer so an option click (which fires before blur settles) wins. // Defer so an option click (which fires before blur settles) wins.
setTimeout(function () { setTimeout(function () {
if (container._ssDirty && search.value.trim() === "") { if (container._searchSelectDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect. // User intentionally cleared the box → deselect.
pills.innerHTML = ""; pills.innerHTML = "";
container._ssLabel = ""; container._searchSelectLabel = "";
emitChange(null); emitChange(null);
} else { } else {
// Focused-and-left, or typed a partial query without picking → // Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection). // restore the committed label (no-op right after a selection).
search.value = container._ssLabel || ""; search.value = container._searchSelectLabel || "";
} }
}, 120); }, 120);
}); });
} }
// Clicking an option must not blur the input before the click selects. // Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", function (e) { options.addEventListener("mousedown", function (event) {
e.preventDefault(); event.preventDefault();
}); });
// ── Option click → select ── // ── Option click → select (form mode) or include/exclude (filter mode) ──
options.addEventListener("click", function (e) { options.addEventListener("click", function (event) {
var row = e.target.closest("[data-ss-option]"); if (isFilter) {
handleFilterOptionClick(event);
return;
}
var row = event.target.closest("[data-search-select-option]");
if (!row) return; if (!row) return;
var option = optionFromRow(row); selectOption(optionFromRow(row));
selectOption(option);
}); });
function handleFilterOptionClick(event) {
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
var modifierRow = event.target.closest("[data-search-select-modifier-option]");
if (modifierRow) {
setModifier(
modifierRow.getAttribute("data-search-select-modifier-option"),
modifierRow.getAttribute("data-label")
);
return;
}
// Include / exclude button on a value row.
var button = event.target.closest("[data-search-select-action]");
if (!button) return;
var row = button.closest("[data-search-select-option]");
if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
}
// Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears an active modifier — the two are mutually exclusive.
function addFilterPill(option, kind) {
clearModifier();
var existing = pills.querySelector(
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
);
if (existing) existing.remove();
pills.appendChild(buildFilterValuePill(option, kind));
emitChange(null);
}
function buildFilterValuePill(option, kind) {
var pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label);
applyData(pill, option.data);
setLabel(pill, option.label);
return pill;
}
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
function setModifier(modifierValue, label) {
pills.innerHTML = "";
var pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.appendChild(pill);
container.setAttribute("data-modifier", modifierValue);
hidePanel();
emitChange(null);
}
function clearModifier() {
var modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
}
function optionFromRow(row) { function optionFromRow(row) {
if (row._ssOption) return row._ssOption; if (row._searchSelectOption) return row._searchSelectOption;
var data = {}; var data = {};
Object.keys(row.dataset).forEach(function (key) { Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") { if (key !== "value" && key !== "label" && key !== "ssOption") {
@@ -192,39 +310,29 @@
} }
} else { } else {
// Single-select: no pill — show the label in the search box and keep a // Single-select: no pill — show the label in the search box and keep a
// lone hidden input under [data-ss-pills] for submission. // lone hidden input under [data-search-select-pills] for submission.
pills.innerHTML = ""; pills.innerHTML = "";
pills.appendChild(buildHidden(option.value)); pills.appendChild(buildHidden(option.value));
search.value = option.label; search.value = option.label;
container._ssLabel = option.label; container._searchSelectLabel = option.label;
container._ssDirty = false; container._searchSelectDirty = false;
hidePanel(); hidePanel();
} }
emitChange(option); emitChange(option);
} }
function addPill(option) { function addPill(option) {
pills.appendChild(buildPill(option)); var pill = buildPill(option);
if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value)); pills.appendChild(buildHidden(option.value));
} }
function buildPill(option) { function buildPill(option) {
var pill = document.createElement("span"); var pill = cloneTemplate("pill");
pill.className = PILL_CLASS; if (!pill) return null;
pill.setAttribute("data-pill", "");
pill.setAttribute("data-value", option.value); pill.setAttribute("data-value", option.value);
var data = option.data || {}; applyData(pill, option.data);
Object.keys(data).forEach(function (key) { setLabel(pill, option.label);
pill.setAttribute("data-" + key, data[key]);
});
pill.appendChild(document.createTextNode(option.label));
var remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("data-pill-remove", "");
remove.className = PILL_REMOVE_CLASS;
remove.setAttribute("aria-label", "Remove");
remove.textContent = "×";
pill.appendChild(remove);
return pill; return pill;
} }
@@ -237,11 +345,21 @@
} }
// ── Pill × → remove ── // ── Pill × → remove ──
pills.addEventListener("click", function (e) { pills.addEventListener("click", function (event) {
var removeBtn = e.target.closest("[data-pill-remove]"); var removeButton = event.target.closest("[data-pill-remove]");
if (!removeBtn) return; if (!removeButton) return;
var pill = removeBtn.closest("[data-pill]"); var pill = removeButton.closest("[data-pill]");
if (!pill) return; if (!pill) return;
if (isFilter) {
// Filter pills have no hidden input; a modifier pill also clears the
// container flag.
if (pill.hasAttribute("data-search-select-modifier")) {
container.removeAttribute("data-modifier");
}
pill.remove();
emitChange(null);
return;
}
var value = pill.getAttribute("data-value"); var value = pill.getAttribute("data-value");
pill.remove(); pill.remove();
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]'); var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
@@ -288,8 +406,8 @@
} }
// ── Close panel on outside click ── // ── Close panel on outside click ──
document.addEventListener("click", function (e) { document.addEventListener("click", function (event) {
if (!container.contains(e.target)) hidePanel(); if (!container.contains(event.target)) hidePanel();
}); });
} }
@@ -298,11 +416,39 @@
return String(value).replace(/["\\]/g, "\\$&"); return String(value).replace(/["\\]/g, "\\$&");
} }
// Forward-looking hook (parallels readSelectableFilters): write each widget's // Serialise each widget's current state onto data-* attributes for the caller.
// current values to a data-values JSON attribute. // 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) { window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) { form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-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 var values = pills
? Array.prototype.map.call( ? Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'), 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, content,
title="Manage games", title="Manage games",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js") + ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+1 -1
View File
@@ -142,7 +142,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
content, content,
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js") + ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+1 -1
View File
@@ -182,7 +182,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
content, content,
title="Manage sessions", title="Manage sessions",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js") + ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
'@parcel/watcher': false
+4 -23
View File
@@ -15,7 +15,6 @@ from django.test import TestCase
from common.components import ( from common.components import (
FilterBar, FilterBar,
PurchaseFilterBar, PurchaseFilterBar,
SelectableFilter,
SessionFilterBar, SessionFilterBar,
) )
from games.models import Device, Game, Platform from games.models import Device, Game, Platform
@@ -94,14 +93,15 @@ class FilterBarRenderingTest(TestCase):
self._assert_range_slider(html) self._assert_range_slider(html)
def test_game_filter_bar_roundtrips_selected_status(self): def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as a selected tag in the widget.""" """A status in filter_json renders as an include pill in the widget."""
filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}}) filter_json = json.dumps({"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}})
html = str( html = str(
FilterBar( FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s" filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
) )
) )
self.assertIn("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('data-value="f"', html) # selected status reflected in widget
self.assertIn("Finished", html) # ...with its label self.assertIn("Finished", html) # ...with its label
self.assertNoEscapedTags(html) self.assertNoEscapedTags(html)
@@ -110,22 +110,3 @@ class FilterBarRenderingTest(TestCase):
# for the double-escape bug the dedup fixed. # for the double-escape bug the dedup fixed.
self.assertIn("&quot;status&quot;", html) self.assertIn("&quot;status&quot;", html)
self.assertNotIn("&amp;quot;", html) self.assertNotIn("&amp;quot;", html)
class SelectableFilterTest(TestCase):
"""The shared widget the deduped FilterBar will be built on."""
OPTIONS = [("f", "Finished"), ("a", "Abandoned"), ("u", "Unplayed")]
def test_plain_widget_has_no_tags(self):
html = str(SelectableFilter("status", self.OPTIONS))
self.assertNotIn("sf-tag", html)
def test_include_and_exclude_tags(self):
html = str(
SelectableFilter("status", self.OPTIONS, selected=["f"], excluded=["a"])
)
self.assertIn('data-type="include"', html)
self.assertIn('data-type="exclude"', html)
self.assertIn("Finished", html)
self.assertIn("Abandoned", html)
+50 -18
View File
@@ -10,6 +10,7 @@ from common.criteria import (
ChoiceCriterion, ChoiceCriterion,
IntCriterion, IntCriterion,
Modifier, Modifier,
MultiCriterion,
StringCriterion, StringCriterion,
) )
from common.components import FilterBar from common.components import FilterBar
@@ -98,6 +99,38 @@ class TestChoiceCriterion:
assert c.to_q("status") == ~Q(status__in=["f"]) assert c.to_q("status") == ~Q(status__in=["f"])
class TestMultiCriterion:
def test_includes(self):
c = MultiCriterion(value=[797], modifier=Modifier.INCLUDES)
assert c.to_q("game_id") == Q(game_id__in=[797])
def test_excludes_only_empty_value(self):
"""Exclude one device with no includes — value=[], excludes=[11].
Regression: an empty ``value`` must not add ``__in=[]`` (which matches
nothing); the criterion should mean "all rows except device 11".
"""
c = MultiCriterion(value=[], excludes=[11], modifier=Modifier.INCLUDES)
assert c.to_q("device_id") == ~Q(device_id__in=[11])
def test_include_and_exclude(self):
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.INCLUDES)
assert c.to_q("game_id") == Q(game_id__in=[1]) & ~Q(game_id__in=[2])
def test_is_null(self):
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)
assert c.to_q("device_id") == Q(device_id__isnull=True)
def test_from_json_strips_embedded_labels(self):
"""from_json normalises {id, label} dicts to bare ids."""
c = MultiCriterion.from_json(
{"value": [{"id": 797, "label": "Hollow Knight"}], "excludes": [{"id": 11, "label": "Steam Deck"}]}
)
assert c.value == [797]
assert c.excludes == [11]
assert c.to_q("game_id") == Q(game_id__in=[797]) & ~Q(game_id__in=[11])
class TestChoiceCriterionAgainstDB: class TestChoiceCriterionAgainstDB:
"""Verify ChoiceCriterion produces correct DB results.""" """Verify ChoiceCriterion produces correct DB results."""
@@ -235,20 +268,20 @@ class TestGameFilterToQ:
class TestFilterBarRendering: class TestFilterBarRendering:
"""Tests for FilterBar with SelectableFilter widgets.""" """Tests for FilterBar with FilterSelect widgets."""
def test_status_uses_selectable_filter(self): def test_status_uses_filter_select(self):
html = str(FilterBar(platform_options=[])) html = str(FilterBar())
assert "data-selectable-filter" in html assert 'data-search-select-mode="filter"' in html
assert 'data-name="status"' in html
def test_mastered_not_checked_by_default(self): def test_mastered_not_checked_by_default(self):
html = str(FilterBar(filter_json="", platform_options=[])) html = str(FilterBar(filter_json=""))
assert 'checked="true"' not in html assert 'checked="true"' not in html
def test_mastered_checked_when_filtered(self): def test_mastered_checked_when_filtered(self):
html = str( html = str(
FilterBar( FilterBar(
platform_options=[],
filter_json=json.dumps( filter_json=json.dumps(
{"mastered": {"value": True, "modifier": "EQUALS"}} {"mastered": {"value": True, "modifier": "EQUALS"}}
), ),
@@ -259,9 +292,8 @@ class TestFilterBarRendering:
def test_status_prefilled(self): def test_status_prefilled(self):
html = str( html = str(
FilterBar( FilterBar(
platform_options=[],
filter_json=json.dumps( filter_json=json.dumps(
{"status": {"value": ["f"], "modifier": "INCLUDES"}} {"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}}
), ),
) )
) )
@@ -269,19 +301,19 @@ class TestFilterBarRendering:
assert "Finished" in html assert "Finished" in html
def test_no_hx_get(self): def test_no_hx_get(self):
html = str(FilterBar(platform_options=[])) html = str(FilterBar())
assert "hx-get" not in html assert "hx-get" not in html
def test_platform_options_rendered(self): def test_platform_uses_search_url(self):
html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")])) """Platform is model-backed: rows are fetched, not pre-rendered."""
assert "Steam" in html html = str(FilterBar())
assert "Switch" in html assert 'data-search-url="/api/platforms/search"' in html
def test_status_has_no_modifiers(self): def test_status_has_no_modifiers(self):
"""Non-nullable fields should not show (None) but MUST show (Any).""" """Non-nullable fields should not show (None) but MUST show (Any)."""
html = str(FilterBar(platform_options=[])) html = str(FilterBar())
status_start = html.find('data-selectable-filter="status"') status_start = html.find('data-name="status"')
platform_start = html.find('data-selectable-filter="platform"') platform_start = html.find('data-name="platform"')
status_section = html[status_start:platform_start] status_section = html[status_start:platform_start]
# Must have (Any) — always available # Must have (Any) — always available
assert "(Any)" in status_section assert "(Any)" in status_section
@@ -290,8 +322,8 @@ class TestFilterBarRendering:
def test_platform_has_modifiers(self): def test_platform_has_modifiers(self):
"""Nullable ForeignKey fields should show (Any)/(None).""" """Nullable ForeignKey fields should show (Any)/(None)."""
html = str(FilterBar(platform_options=[(1, "Steam")])) html = str(FilterBar())
platform_start = html.find('data-selectable-filter="platform"') platform_start = html.find('data-name="platform"')
platform_section = html[platform_start:] platform_section = html[platform_start:]
# Should have at least one modifier option # Should have at least one modifier option
assert "(Any)" in platform_section or "(None)" in platform_section assert "(Any)" in platform_section or "(None)" in platform_section
+121 -6
View File
@@ -7,6 +7,7 @@ import django.test
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components import ( from common.components import (
FilterSelect,
Pill, Pill,
SearchSelect, SearchSelect,
searchselect_selected, searchselect_selected,
@@ -51,7 +52,7 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_empty_options_renders_no_results_scaffold(self): def test_empty_options_renders_no_results_scaffold(self):
html = SearchSelect(name="games") html = SearchSelect(name="games")
self.assertIn("data-ss-no-results", html) self.assertIn("data-search-select-no-results", html)
self.assertIn("No results", html) self.assertIn("No results", html)
def test_outer_container_carries_config(self): def test_outer_container_carries_config(self):
@@ -70,7 +71,7 @@ class SearchSelectComponentTest(unittest.TestCase):
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
) )
self.assertIn("data-pill", html) self.assertIn("data-pill", html)
self.assertIn('<input type="hidden" name="games" value="7">', html) self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('data-platform="2"', html) self.assertIn('data-platform="2"', html)
# exactly one submitted value (the hidden input) — the search box has no # exactly one submitted value (the hidden input) — the search box has no
# name. The leading space avoids matching the container's data-name. # name. The leading space avoids matching the container's data-name.
@@ -85,18 +86,18 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertNotIn("data-pill", html) self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html) self.assertIn('value="Game A"', html)
# the value is still submitted via a lone hidden input # the value is still submitted via a lone hidden input
self.assertIn('<input type="hidden" name="games" value="7">', html) self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertEqual(html.count(' name="games"'), 1) self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self): def test_search_box_has_no_name(self):
html = SearchSelect(name="games") html = SearchSelect(name="games")
self.assertIn("data-ss-search", html) self.assertIn("data-search-select-search", html)
# container exposes data-name, never a submittable name on the search box # container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0) self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self): def test_tuple_options_are_normalized(self):
html = SearchSelect(name="t", options=[("1", "One")]) html = SearchSelect(name="t", options=[("1", "One")])
self.assertIn('data-ss-option=""', html) self.assertIn('data-search-select-option=""', html)
self.assertIn('data-value="1"', html) self.assertIn('data-value="1"', html)
self.assertIn("One", html) self.assertIn("One", html)
@@ -104,7 +105,121 @@ class SearchSelectComponentTest(unittest.TestCase):
html = SearchSelect( html = SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search" name="t", options=[("1", "One")], search_url="/api/games/search"
) )
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): class SearchLabelTest(django.test.TestCase):
Generated
+67 -44
View File
@@ -149,25 +149,25 @@ wheels = [
[[package]] [[package]]
name = "distlib" name = "distlib"
version = "0.4.0" version = "0.4.2"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "django" name = "django"
version = "6.0.5" version = "6.0.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/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 = [ 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]] [[package]]
@@ -269,7 +269,7 @@ wheels = [
[[package]] [[package]]
name = "djlint" name = "djlint"
version = "1.36.4" version = "1.39.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -282,13 +282,36 @@ dependencies = [
{ name = "regex" }, { name = "regex" },
{ name = "tqdm" }, { 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 = [ 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/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/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/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/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/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/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/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/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/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]] [[package]]
@@ -302,11 +325,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.29.0" version = "3.29.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -341,11 +364,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.17" version = "3.18"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -817,27 +840,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.15" version = "0.15.16"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@@ -923,14 +946,14 @@ dev = [
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.3" version = "4.68.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/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 = [ 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]] [[package]]
@@ -987,7 +1010,7 @@ wheels = [
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.4.1" version = "21.4.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
@@ -995,7 +1018,7 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "python-discovery" }, { 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 = [ 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" },
] ]