Remove the bespoke SelectableFilter widget

FilterSelect fully replaces it: delete SelectableFilter and its _selectable_*
helpers, the now-unused _get_filter_options, selectable_filter.js, and the .sf-*
rules in input.css (rebuilt base.css). The three list views load search_select.js
instead of selectable_filter.js. Drop the SelectableFilter export and refresh
docs/comments that referenced it.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
Claude
2026-06-07 22:26:17 +00:00
committed by Lukáš Kucharczyk
parent 1a206d719b
commit 12b0b0af61
11 changed files with 13 additions and 572 deletions
+3 -4
View File
@@ -64,8 +64,8 @@ docs/ — Additional documentation
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs. - **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()` - **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()` - **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips) - **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js` - **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering. **Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
@@ -118,8 +118,7 @@ Only a small number of HTML templates remain (platform icon snippets and partial
- **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css` - **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- **Custom JS** in `games/static/js/`: - **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event) - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
- `selectable_filter.js` — SelectableFilter widget interaction - `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`) - `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment ### Deployment
-2
View File
@@ -59,7 +59,6 @@ from common.components.domain import (
from common.components.filters import ( from common.components.filters import (
FilterBar, FilterBar,
PurchaseFilterBar, PurchaseFilterBar,
SelectableFilter,
SessionFilterBar, SessionFilterBar,
) )
@@ -109,6 +108,5 @@ __all__ = [
"_resolve_name_with_icon", "_resolve_name_with_icon",
"FilterBar", "FilterBar",
"PurchaseFilterBar", "PurchaseFilterBar",
"SelectableFilter",
"SessionFilterBar", "SessionFilterBar",
] ]
+2 -199
View File
@@ -1,4 +1,4 @@
"""Stash-style filter bars and the SelectableFilter widget.""" """Stash-style filter bars, built from FilterSelect widgets."""
from typing import NamedTuple from typing import NamedTuple
@@ -12,7 +12,7 @@ from common.components.search_select import FilterSelect
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: list[str]
excluded: list[str] excluded: list[str]
@@ -84,20 +84,6 @@ 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]]:
"""Return (value, label) pairs for a SelectableFilter from model rows.
Uses values_list for efficiency (only fetches needed columns),
but unpacks each row into readable local variables.
"""
options: list[tuple[str, str]] = []
for object_id, object_name in model_class.objects.order_by(order_by).values_list(
"id", order_by
):
options.append((str(object_id), object_name))
return options
# ── FilterSelect adapters ──────────────────────────────────────────────────── # ── FilterSelect adapters ────────────────────────────────────────────────────
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed # Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
# option set; model-backed fields fetch from a search endpoint and only resolve # option set; model-backed fields fetch from a search endpoint and only resolve
@@ -742,189 +728,6 @@ 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(
value: str, label: str, *, excluded: bool = False
) -> SafeText:
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
checkmark = "\u2717" if excluded else "\u2713"
css = "sf-tag sf-excluded" if excluded else "sf-tag"
return Span(
attributes=[
("class", css),
("data-value", value),
("data-type", "exclude" if excluded else "include"),
],
children=[
Span(
attributes=[("class", "sf-tag-text")],
children=[f"{checkmark} {label}"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-remove"),
("aria-label", "Remove"),
],
children=["\u00d7"],
),
],
)
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
return Span(
attributes=[
("class", "sf-modifier-tag active"),
("data-modifier", modifier),
],
children=[label],
)
def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
"""A modifier choice in the SelectableFilter dropdown list."""
return Component(
tag_name="div",
attributes=[
("class", "sf-option sf-modifier-option"),
("data-modifier", modifier),
("data-label", label),
],
children=[
Span(
attributes=[("class", "sf-option-label")],
children=[label],
),
],
)
def _selectable_filter_option(value: str, label: str) -> SafeText:
"""An option row with include (+) and exclude (\u2212) buttons."""
return Component(
tag_name="div",
attributes=[
("class", "sf-option"),
("data-value", value),
("data-label", label),
],
children=[
Span(
attributes=[("class", "sf-option-label")],
children=[label],
),
Span(
attributes=[("class", "sf-option-buttons")],
children=[
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-btn-include"),
("data-action", "include"),
("title", "Include"),
],
children=["+"],
),
Component(
tag_name="button",
attributes=[
("type", "button"),
("class", "sf-btn-exclude"),
("data-action", "exclude"),
("title", "Exclude"),
],
children=["\u2212"],
),
],
),
],
)
def SelectableFilter(
field_name: str,
options: list[tuple[str, str]],
selected: list[str] | None = None,
excluded: list[str] | None = None,
modifier: str = "",
nullable: bool = True,
) -> "SafeText":
"""Stash-style selectable filter with search, include/exclude, modifier tags."""
selected = selected or []
excluded = excluded or []
modifier_options = [("NOT_NULL", "(Any)")]
if nullable:
modifier_options.append(("IS_NULL", "(None)"))
active_modifier_tag = ""
inactive_modifier_options: list[SafeText] = []
for modifier_value, modifier_label in modifier_options:
if modifier == modifier_value:
active_modifier_tag = _selectable_filter_modifier_tag(
modifier_value, modifier_label
)
else:
inactive_modifier_options.append(
_selectable_filter_modifier_option(modifier_value, modifier_label)
)
selected_tags: list[SafeText] = []
for value in selected:
selected_tags.append(
_selectable_filter_tag(value, _find_label(options, value), excluded=False)
)
for value in excluded:
selected_tags.append(
_selectable_filter_tag(value, _find_label(options, value), excluded=True)
)
option_rows: list[SafeText] = []
for value, label in options:
option_rows.append(_selectable_filter_option(value, label))
selected_area_children: list[SafeText] = []
if active_modifier_tag:
selected_area_children.append(active_modifier_tag)
selected_area_children.extend(selected_tags)
options_area_children: list[SafeText] = []
options_area_children.extend(inactive_modifier_options)
options_area_children.extend(option_rows)
return Component(
tag_name="div",
attributes=[
("class", "sf-container"),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
Component(
tag_name="div",
attributes=[("class", "sf-selected")],
children=selected_area_children,
),
Component(
tag_name="input",
attributes=[
("type", "text"),
("class", "sf-search"),
("placeholder", "Search\u2026"),
],
),
Component(
tag_name="div",
attributes=[("class", "sf-options")],
children=options_area_children,
),
],
)
def _find_label(options: list[tuple[str, str]], value: str) -> str: def _find_label(options: list[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):
+1 -1
View File
@@ -299,7 +299,7 @@ class MultiCriterion(_Criterion):
class ChoiceCriterion(_Criterion): class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude. """Filter on a choice/enum field with multi-select include/exclude.
Used by SelectableFilter widgets for status, ownership_type, etc. Used by FilterSelect widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers. Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
""" """
-45
View File
@@ -232,48 +232,3 @@ textarea:disabled {
} }
} }
/* SelectableFilter widget styling */
.sf-container {
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
}
.sf-selected {
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
}
.sf-tag {
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
}
.sf-tag.sf-excluded {
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
}
.sf-remove {
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
}
.sf-modifier-tag {
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
}
.sf-search {
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
&:focus {
@apply ring-0 outline-hidden;
}
}
.sf-options {
@apply max-h-40 overflow-y-auto p-1 text-body;
}
.sf-option {
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
}
.sf-option-label {
@apply truncate;
}
.sf-option-buttons {
@apply flex gap-1 ml-2 shrink-0;
}
.sf-btn-include,
.sf-btn-exclude {
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
}
.sf-modifier-option {
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
}
-165
View File
@@ -4429,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;
+4 -4
View File
@@ -12,8 +12,8 @@
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their * pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar. * state into data-included / data-excluded / data-modifier for the filter bar.
* *
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap, * initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
* each widget guarded with el._ssInit. * el._ssInit.
* *
* The pill / option class strings below are kept byte-identical to the Python * The pill / option class strings below are kept byte-identical to the Python
* Pill / SearchSelect / FilterSelect components so Tailwind generates the classes * Pill / SearchSelect / FilterSelect components so Tailwind generates the classes
@@ -496,8 +496,8 @@
// Serialise each widget's current state onto data-* attributes for the caller. // Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter // Form widgets expose data-values (the submitted hidden-input values); filter
// widgets (parallel to readSelectableFilters) expose data-included / // widgets expose data-included / data-excluded / data-modifier for the filter
// data-excluded / data-modifier for the filter bar to read. // 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-ss-pills]");
-149
View File
@@ -1,149 +0,0 @@
/**
* SelectableFilter widget Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
+1 -1
View File
@@ -149,7 +149,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
content, content,
title="Manage games", title="Manage games",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js") + ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+1 -1
View File
@@ -142,7 +142,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
content, content,
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js") + ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )
+1 -1
View File
@@ -182,7 +182,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
content, content,
title="Manage sessions", title="Manage sessions",
scripts=ModuleScript("range_slider.js") scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js") + ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"), + ModuleScript("filter_bar.js"),
) )