Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05534875d6 | |||
| 428edbcfe8 | |||
| 11cd62a3b9 | |||
| d9902146dc | |||
| 83cbac9505 | |||
| 0285243172 | |||
| 112d3107ef | |||
| 22d7834ae9 | |||
| 60773e7755 | |||
| 79fa4bef44 | |||
| 15bb3ce1b9 | |||
| a06e772e42 | |||
| 29b42e0f3d | |||
| f210f818a9 | |||
| 6bc7da9f2f | |||
| c9189b9f8e | |||
| a37257f9c8 | |||
| db047dfaf2 | |||
| 6aff12b7b2 | |||
| 12b0b0af61 | |||
| 1a206d719b | |||
| a6532807cb | |||
| d7e6efa68a | |||
| 003e6ebe15 | |||
| e2cbd4a9f4 |
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
|
||||||
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
|
|
||||||
children=["No results"],
|
|
||||||
)
|
|
||||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
|
||||||
options_panel = Component(
|
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
|
||||||
("data-ss-options", ""),
|
|
||||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
|
||||||
("class", options_class),
|
|
||||||
],
|
|
||||||
children=[*option_rows, no_results],
|
|
||||||
)
|
|
||||||
|
|
||||||
container_attrs: list[HTMLAttribute] = [
|
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||||
|
# multi-select adds chosen items. ──
|
||||||
|
templates: list[SafeText] = []
|
||||||
|
if search_url:
|
||||||
|
templates.append(
|
||||||
|
Template(
|
||||||
|
attributes=[("data-search-select-template", "row")],
|
||||||
|
children=[_option_row(_BLANK_OPTION)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if multi_select:
|
||||||
|
templates.append(
|
||||||
|
Template(
|
||||||
|
attributes=[("data-search-select-template", "pill")],
|
||||||
|
children=[Pill("", value="", removable=True, label_slot=True)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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
@@ -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 ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||||
|
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||||
|
function fetchFromServer(query) {
|
||||||
|
if (pendingRequest) pendingRequest.abort();
|
||||||
|
pendingRequest = new AbortController();
|
||||||
|
var url = searchUrl + "?q=" + encodeURIComponent(query);
|
||||||
|
if (prefetch && !query) url += "&limit=" + prefetch;
|
||||||
|
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||||
|
.then(function (response) {
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (items) {
|
||||||
|
pendingRequest = null;
|
||||||
|
renderRows(items);
|
||||||
|
// Re-apply the live query: the box may hold more text than was sent.
|
||||||
|
setNoResults(filterRows(search.value.trim()) === 0);
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
if (error && error.name === "AbortError") return; // superseded
|
||||||
|
pendingRequest = null;
|
||||||
|
setNoResults(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called on every keystroke. With a search_url, filter the loaded window
|
||||||
|
// instantly (zero latency) and debounce a server request for the rest;
|
||||||
|
// no-results stays hidden until the response decides it, to avoid a flash
|
||||||
|
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||||
|
// so the client-side filter is authoritative.
|
||||||
function runSearch() {
|
function runSearch() {
|
||||||
var q = search.value.trim();
|
var query = search.value.trim();
|
||||||
if (searchUrl && q) {
|
showPanel();
|
||||||
|
if (searchUrl) {
|
||||||
|
filterRows(query);
|
||||||
|
setNoResults(false);
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(function () {
|
debounceTimer = setTimeout(function () {
|
||||||
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
|
fetchFromServer(query);
|
||||||
credentials: "same-origin",
|
|
||||||
})
|
|
||||||
.then(function (r) {
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(renderRows)
|
|
||||||
.catch(function () {
|
|
||||||
setNoResults(true);
|
|
||||||
});
|
|
||||||
}, 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"]'),
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(""status"", html)
|
self.assertIn(""status"", html)
|
||||||
self.assertNotIn("&quot;", html)
|
self.assertNotIn("&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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.15'",
|
"python_full_version < '3.15'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[options]
|
|
||||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
|
||||||
exclude-newer-span = "P7D"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -153,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]]
|
||||||
@@ -273,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" },
|
||||||
@@ -286,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]]
|
||||||
@@ -306,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]]
|
||||||
@@ -345,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]]
|
||||||
@@ -821,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]]
|
||||||
@@ -927,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]]
|
||||||
@@ -991,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" },
|
||||||
@@ -999,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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user