Numeric range filters could only express BETWEEN/GREATER_THAN/LESS_THAN via the RangeSlider widget — no way to match NULL/missing values (the original ask in #32) or exact/not-between. The criteria backend already supported all 8 numeric modifiers + value2, so this is a UI swap. - Add NumberFilter component, modeled 1:1 on StringFilter: an 8-modifier radio grid plus two number inputs, with the second input revealed only for BETWEEN/NOT_BETWEEN and both disabled for IS_NULL/NOT_NULL. Initial state is server-rendered so the widget never flashes. - Migrate all 17 numeric range fields (game/session/purchase/playevent) to NumberFilter; drop the now-dead min/max aggregate queries. - filter-bar.ts: serialize numberFields by modifier (mirroring textFields) and wire the modifier radios via event delegation on the persistent custom element so they survive htmx swaps of the inner bar body. Apply the same delegation fix to the existing string filters. - Remove RangeSlider entirely: component, range-slider.ts, its custom element registration/props, and the range-slider e2e tests. Backward compatible: old slider filters stored {value, value2, modifier}, the same JSON shape NumberFilter reads, so saved presets keep working. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ from common.components.domain import (
|
||||
from common.components.filters import (
|
||||
DeviceFilterBar,
|
||||
FilterBar,
|
||||
NumberFilter,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
PurchaseFilterBar,
|
||||
@@ -176,4 +177,5 @@ __all__ = [
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
"StringFilter",
|
||||
"NumberFilter",
|
||||
]
|
||||
|
||||
@@ -146,17 +146,6 @@ register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
|
||||
_SelectionFields = custom_element_builder("selection-fields")
|
||||
|
||||
|
||||
class RangeSliderProps(TypedDict):
|
||||
min: int
|
||||
max: int
|
||||
step: int
|
||||
mode: str # "range" | "point"
|
||||
|
||||
|
||||
register_element("range-slider", "RangeSlider", RangeSliderProps)
|
||||
_RangeSlider = custom_element_builder("range-slider")
|
||||
|
||||
|
||||
class DateRangePickerProps(TypedDict):
|
||||
pass
|
||||
|
||||
|
||||
+268
-453
@@ -2,10 +2,8 @@
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from django.db import models
|
||||
|
||||
from common.components.core import BaseComponent, Element, Node, Safe
|
||||
from common.components.custom_elements import _FilterBarElement, _RangeSlider
|
||||
from common.components.custom_elements import _FilterBarElement
|
||||
from common.components.date_range_picker import DateRangePicker
|
||||
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
|
||||
from common.components.search_select import (
|
||||
@@ -35,6 +33,14 @@ class RangeValues(NamedTuple):
|
||||
max: str
|
||||
|
||||
|
||||
class NumberValues(NamedTuple):
|
||||
"""(value, value2, modifier) parsed from a numeric filter criterion."""
|
||||
|
||||
value: str
|
||||
value2: str
|
||||
modifier: str
|
||||
|
||||
|
||||
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
|
||||
|
||||
|
||||
@@ -99,6 +105,22 @@ def _parse_range(existing: dict, key: str) -> RangeValues:
|
||||
return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
|
||||
|
||||
|
||||
def _parse_number(existing: dict, key: str) -> NumberValues:
|
||||
"""Extract (value, value2, modifier) from a numeric filter criterion.
|
||||
|
||||
Backward compatible with old RangeSlider JSON: a stored GREATER_THAN /
|
||||
LESS_THAN / BETWEEN criterion maps straight onto value/value2/modifier.
|
||||
"""
|
||||
field = existing.get(key, {})
|
||||
if not isinstance(field, dict):
|
||||
return NumberValues("", "", "EQUALS")
|
||||
return NumberValues(
|
||||
str(field.get("value", "")),
|
||||
str(field.get("value2", "")),
|
||||
str(field.get("modifier") or "EQUALS"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool(existing: dict, key: str) -> bool:
|
||||
"""Extract a boolean value from a filter criterion."""
|
||||
field = existing.get(key, {})
|
||||
@@ -299,204 +321,6 @@ def _filter_boolean_radio(name: str, label: str, value: bool | None) -> Node:
|
||||
)
|
||||
|
||||
|
||||
# SVG icons for the mode toggle (shared across all RangeSliders)
|
||||
_RANGE_ICON_SVG = (
|
||||
'<svg width="16" height="10" viewBox="0 0 16 10">'
|
||||
'<line x1="3" y1="5" x2="13" y2="5" stroke="currentColor" stroke-width="1.5"/>'
|
||||
'<circle cx="3" cy="5" r="3" fill="currentColor"/>'
|
||||
'<circle cx="13" cy="5" r="3" fill="currentColor"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
_POINT_ICON_SVG = (
|
||||
'<svg width="16" height="10" viewBox="0 0 16 10">'
|
||||
'<circle cx="8" cy="5" r="3" fill="currentColor"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
_RANGE_SLIDER_INPUT_CLASS = (
|
||||
"w-24 rounded-base border border-default-medium bg-neutral-secondary-medium "
|
||||
"text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
|
||||
)
|
||||
|
||||
|
||||
def RangeSlider(
|
||||
*,
|
||||
label: str,
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
range_min: int,
|
||||
range_max: int,
|
||||
step: str = "1",
|
||||
min_placeholder: str = "",
|
||||
max_placeholder: str = "",
|
||||
) -> Node:
|
||||
"""A labelled range slider with number inputs and range/point mode toggle.
|
||||
|
||||
Renders a label row (label, two number inputs, toggle button) and a slider
|
||||
row (track with one or two custom draggable handles). Defaults to range mode
|
||||
(two handles). If min_value and max_value are both set and equal, starts in
|
||||
point mode (single handle). The toggle switches between modes.
|
||||
"""
|
||||
min_input_id = f"{input_name_prefix}-min"
|
||||
max_input_id = f"{input_name_prefix}-max"
|
||||
point_mode = bool(min_value and max_value and min_value == max_value)
|
||||
initial_mode = "point" if point_mode else "range"
|
||||
|
||||
return _RangeSlider(
|
||||
min=range_min,
|
||||
max=range_max,
|
||||
step=int(step),
|
||||
mode=initial_mode,
|
||||
class_="mb-4 block",
|
||||
)[
|
||||
# ── Label row ──
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||
children=[
|
||||
# The field label is rendered by the _filter_field wrapper.
|
||||
# This composite widget has no single labelable root, so the
|
||||
# label carries no `for` (the two inputs are named below).
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", min_input_id),
|
||||
("id", min_input_id),
|
||||
("value", min_value),
|
||||
("placeholder", min_placeholder),
|
||||
(
|
||||
"class",
|
||||
f"{_RANGE_SLIDER_INPUT_CLASS}"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-dash text-body text-sm"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=["–"],
|
||||
),
|
||||
Input(
|
||||
attributes=[
|
||||
("type", "number"),
|
||||
("name", max_input_id),
|
||||
("id", max_input_id),
|
||||
("value", max_value),
|
||||
("placeholder", max_placeholder),
|
||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||
],
|
||||
),
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(
|
||||
"class",
|
||||
"range-mode-toggle p-1 text-body hover:text-heading "
|
||||
"rounded cursor-pointer shrink-0",
|
||||
),
|
||||
(
|
||||
"title",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
(
|
||||
"aria-label",
|
||||
"Toggle between range and single value",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-range"
|
||||
+ (" hidden" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
children=[Safe(_RANGE_ICON_SVG)],
|
||||
),
|
||||
Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-mode-icon-point"
|
||||
+ ("" if point_mode else " hidden"),
|
||||
),
|
||||
],
|
||||
children=[Safe(_POINT_ICON_SVG)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
# ── Track row ──
|
||||
Div(
|
||||
attributes=[
|
||||
("class", "relative h-10 w-5/6 select-none mt-1"),
|
||||
("data-range-track", ""),
|
||||
],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
|
||||
"rounded-full bg-neutral-quaternary",
|
||||
),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-track-fill absolute top-1/2 -translate-y-1/2 "
|
||||
"h-2 bg-brand rounded-full",
|
||||
),
|
||||
("style", "left:0;width:100%"),
|
||||
],
|
||||
),
|
||||
# Min handle (hidden in point mode via JS)
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-min absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", min_input_id),
|
||||
(
|
||||
"style",
|
||||
"left:0" + (";display:none" if point_mode else ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
# Max handle
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"range-handle range-handle-max absolute top-1/2 "
|
||||
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
|
||||
"border-2 border-white shadow cursor-pointer "
|
||||
"hover:scale-110 transition-transform",
|
||||
),
|
||||
("data-target", max_input_id),
|
||||
("style", "left:100%"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
_DATE_RANGE_INPUT_CLASS = (
|
||||
"w-full rounded-base border border-default-medium bg-neutral-secondary-medium "
|
||||
"text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
|
||||
@@ -514,11 +338,10 @@ def DateRangeFilter(
|
||||
) -> Node:
|
||||
"""A pair of ``<input type="date">`` elements representing a date range.
|
||||
|
||||
Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and
|
||||
``{prefix}-max``) but without a slider track — the browser's native date
|
||||
picker is the UI. Serialized client-side into a ``DateCriterion`` with
|
||||
``BETWEEN`` / ``GREATER_THAN`` / ``LESS_THAN`` depending on which bound(s)
|
||||
the user filled.
|
||||
Two inputs named ``{prefix}-min`` and ``{prefix}-max`` — the browser's
|
||||
native date picker is the UI. Serialized client-side into a ``DateCriterion``
|
||||
with ``BETWEEN`` / ``GREATER_THAN`` / ``LESS_THAN`` depending on which
|
||||
bound(s) the user filled.
|
||||
"""
|
||||
min_input_id = f"{input_name_prefix}-min"
|
||||
max_input_id = f"{input_name_prefix}-max"
|
||||
@@ -800,71 +623,22 @@ def _game_fields(
|
||||
"modifier", "EQUALS"
|
||||
)
|
||||
|
||||
year_min, year_max = _parse_range(existing, "year_released")
|
||||
original_year_min, original_year_max = _parse_range(
|
||||
existing, "original_year_released"
|
||||
)
|
||||
year = _parse_number(existing, "year_released")
|
||||
original_year = _parse_number(existing, "original_year_released")
|
||||
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||
playtime = existing.get("playtime_hours", {})
|
||||
if isinstance(playtime, dict):
|
||||
playtime_min = playtime.get("value", "")
|
||||
playtime_max = playtime.get("value2", "")
|
||||
else:
|
||||
playtime_min = ""
|
||||
playtime_max = ""
|
||||
|
||||
session_count_min, session_count_max = _parse_range(existing, "session_count")
|
||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
|
||||
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
|
||||
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_hours")
|
||||
calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_hours")
|
||||
price_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
|
||||
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
|
||||
playtime = _parse_number(existing, "playtime_hours")
|
||||
session_count = _parse_number(existing, "session_count")
|
||||
session_avg = _parse_number(existing, "session_average")
|
||||
purchase_count = _parse_number(existing, "purchase_count")
|
||||
playevent_count = _parse_number(existing, "playevent_count")
|
||||
manual_pt = _parse_number(existing, "manual_playtime_hours")
|
||||
calc_pt = _parse_number(existing, "calculated_playtime_hours")
|
||||
price_total = _parse_number(existing, "purchase_price_total")
|
||||
price_any = _parse_number(existing, "purchase_price_any")
|
||||
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
||||
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
|
||||
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
|
||||
|
||||
try:
|
||||
year_aggregate = Game.objects.aggregate(
|
||||
year_min=models.Min("year_released"), year_max=models.Max("year_released")
|
||||
)
|
||||
except Exception:
|
||||
year_aggregate = {}
|
||||
try:
|
||||
original_year_aggregate = Game.objects.aggregate(
|
||||
year_min=models.Min("original_year_released"),
|
||||
year_max=models.Max("original_year_released"),
|
||||
)
|
||||
except Exception:
|
||||
original_year_aggregate = {}
|
||||
try:
|
||||
playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
|
||||
except Exception:
|
||||
playtime_aggregate = {}
|
||||
try:
|
||||
price_aggregate = Purchase.objects.aggregate(
|
||||
price_min=models.Min("converted_price"),
|
||||
price_max=models.Max("converted_price"),
|
||||
)
|
||||
except Exception:
|
||||
price_aggregate = {}
|
||||
year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970)
|
||||
year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030)
|
||||
original_year_range_min = max(
|
||||
int(original_year_aggregate.get("year_min") or 1970), 1970
|
||||
)
|
||||
original_year_range_max = min(
|
||||
int(original_year_aggregate.get("year_max") or 2030), 2030
|
||||
)
|
||||
playtime_range_max = (
|
||||
int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
|
||||
if playtime_aggregate.get("playtime_max")
|
||||
else 200
|
||||
)
|
||||
price_range_min = int(price_aggregate.get("price_min") or 0)
|
||||
price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
|
||||
|
||||
fields = [
|
||||
Div(
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
@@ -934,152 +708,125 @@ def _game_fields(
|
||||
),
|
||||
_filter_field(
|
||||
"Year",
|
||||
RangeSlider(
|
||||
label="Year",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-year",
|
||||
min_value=year_min,
|
||||
max_value=year_max,
|
||||
range_min=year_range_min,
|
||||
range_max=year_range_max,
|
||||
min_placeholder="e.g. 2020",
|
||||
max_placeholder="e.g. 2024",
|
||||
value=year.value,
|
||||
value2=year.value2,
|
||||
modifier=year.modifier,
|
||||
placeholder="e.g. 2020",
|
||||
placeholder2="e.g. 2024",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Original Year",
|
||||
RangeSlider(
|
||||
label="Original Year",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-original-year",
|
||||
min_value=original_year_min,
|
||||
max_value=original_year_max,
|
||||
range_min=original_year_range_min,
|
||||
range_max=original_year_range_max,
|
||||
min_placeholder="e.g. 1985",
|
||||
max_placeholder="e.g. 2010",
|
||||
value=original_year.value,
|
||||
value2=original_year.value2,
|
||||
modifier=original_year.modifier,
|
||||
placeholder="e.g. 1985",
|
||||
placeholder2="e.g. 2010",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Total playtime",
|
||||
RangeSlider(
|
||||
label="Total playtime",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-playtime-hours",
|
||||
min_value=playtime_min,
|
||||
max_value=playtime_max,
|
||||
range_min=0,
|
||||
range_max=playtime_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 100",
|
||||
value=playtime.value,
|
||||
value2=playtime.value2,
|
||||
modifier=playtime.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 100",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Manual Playtime (hrs)",
|
||||
RangeSlider(
|
||||
label="Manual Playtime (hrs)",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-manual-playtime-hours",
|
||||
min_value=manual_pt_min,
|
||||
max_value=manual_pt_max,
|
||||
range_min=0,
|
||||
range_max=max(playtime_range_max, 4),
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 10",
|
||||
value=manual_pt.value,
|
||||
value2=manual_pt.value2,
|
||||
modifier=manual_pt.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 10",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Calculated Playtime (hrs)",
|
||||
RangeSlider(
|
||||
label="Calculated Playtime (hrs)",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-calculated-playtime-hours",
|
||||
min_value=calc_pt_min,
|
||||
max_value=calc_pt_max,
|
||||
range_min=0,
|
||||
range_max=max(playtime_range_max, 4),
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 10",
|
||||
value=calc_pt.value,
|
||||
value2=calc_pt.value2,
|
||||
modifier=calc_pt.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 10",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Session Count",
|
||||
RangeSlider(
|
||||
label="Session Count",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-session-count",
|
||||
min_value=session_count_min,
|
||||
max_value=session_count_max,
|
||||
range_min=0,
|
||||
range_max=100,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 50",
|
||||
value=session_count.value,
|
||||
value2=session_count.value2,
|
||||
modifier=session_count.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 50",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Average Session Duration (mins)",
|
||||
RangeSlider(
|
||||
label="Average Session Duration (mins)",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-session-average",
|
||||
min_value=session_avg_min,
|
||||
max_value=session_avg_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
value=session_avg.value,
|
||||
value2=session_avg.value2,
|
||||
modifier=session_avg.modifier,
|
||||
placeholder="e.g. 10",
|
||||
placeholder2="e.g. 120",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Number of Purchases",
|
||||
RangeSlider(
|
||||
label="Number of Purchases",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-purchase-count",
|
||||
min_value=purchase_count_min,
|
||||
max_value=purchase_count_max,
|
||||
range_min=0,
|
||||
range_max=20,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
value=purchase_count.value,
|
||||
value2=purchase_count.value2,
|
||||
modifier=purchase_count.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 5",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Number of Play Events",
|
||||
RangeSlider(
|
||||
label="Number of Play Events",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-playevent-count",
|
||||
min_value=playevent_count_min,
|
||||
max_value=playevent_count_max,
|
||||
range_min=0,
|
||||
range_max=20,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
value=playevent_count.value,
|
||||
value2=playevent_count.value2,
|
||||
modifier=playevent_count.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 5",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Total Purchase Price",
|
||||
RangeSlider(
|
||||
label="Total Purchase Price",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-purchase-price-total",
|
||||
min_value=price_total_min,
|
||||
max_value=price_total_max,
|
||||
range_min=price_range_min,
|
||||
range_max=price_range_max,
|
||||
min_placeholder="0",
|
||||
max_placeholder=str(price_range_max),
|
||||
value=price_total.value,
|
||||
value2=price_total.value2,
|
||||
modifier=price_total.modifier,
|
||||
placeholder="0",
|
||||
placeholder2="e.g. 100",
|
||||
step="0.01",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Any Purchase Price",
|
||||
RangeSlider(
|
||||
label="Any Purchase Price",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-purchase-price-any",
|
||||
min_value=price_any_min,
|
||||
max_value=price_any_max,
|
||||
range_min=price_range_min,
|
||||
range_max=price_range_max,
|
||||
min_placeholder="0",
|
||||
max_placeholder=str(price_range_max),
|
||||
value=price_any.value,
|
||||
value2=price_any.value2,
|
||||
modifier=price_any.modifier,
|
||||
placeholder="0",
|
||||
placeholder2="e.g. 100",
|
||||
step="0.01",
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1125,23 +872,11 @@ def _session_fields(existing: dict) -> list:
|
||||
note_value = existing.get("note", {}).get("value", "")
|
||||
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
|
||||
|
||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_hours")
|
||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_hours")
|
||||
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_hours")
|
||||
dur_tot = _parse_number(existing, "duration_total_hours")
|
||||
dur_man = _parse_number(existing, "duration_manual_hours")
|
||||
dur_calc = _parse_number(existing, "duration_calculated_hours")
|
||||
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||
try:
|
||||
duration_aggregate = Session.objects.aggregate(
|
||||
duration_max=models.Max("duration_total")
|
||||
)
|
||||
duration_range_max = max(
|
||||
int((duration_aggregate.get("duration_max") or 0).total_seconds() / 3600)
|
||||
if duration_aggregate.get("duration_max")
|
||||
else 200,
|
||||
1,
|
||||
)
|
||||
except Exception:
|
||||
duration_range_max = 200
|
||||
|
||||
fields = [
|
||||
Div(
|
||||
@@ -1176,38 +911,38 @@ def _session_fields(existing: dict) -> list:
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Total Duration (hrs)",
|
||||
input_name_prefix="filter-duration-total-hours",
|
||||
min_value=dur_tot_min,
|
||||
max_value=dur_tot_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 10",
|
||||
_filter_field(
|
||||
"Total Duration (hrs)",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-duration-total-hours",
|
||||
value=dur_tot.value,
|
||||
value2=dur_tot.value2,
|
||||
modifier=dur_tot.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 10",
|
||||
),
|
||||
),
|
||||
RangeSlider(
|
||||
label="Manual Duration (hrs)",
|
||||
input_name_prefix="filter-duration-manual-hours",
|
||||
min_value=dur_man_min,
|
||||
max_value=dur_man_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 10",
|
||||
_filter_field(
|
||||
"Manual Duration (hrs)",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-duration-manual-hours",
|
||||
value=dur_man.value,
|
||||
value2=dur_man.value2,
|
||||
modifier=dur_man.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 10",
|
||||
),
|
||||
),
|
||||
RangeSlider(
|
||||
label="Calculated Duration (hrs)",
|
||||
input_name_prefix="filter-duration-calculated-hours",
|
||||
min_value=dur_calc_min,
|
||||
max_value=dur_calc_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 10",
|
||||
_filter_field(
|
||||
"Calculated Duration (hrs)",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-duration-calculated-hours",
|
||||
value=dur_calc.value,
|
||||
value2=dur_calc.value2,
|
||||
modifier=dur_calc.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 10",
|
||||
),
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "flex gap-6 mb-4")],
|
||||
@@ -1236,7 +971,7 @@ def _purchase_fields(existing: dict) -> list:
|
||||
platform_choice = _filter_get_choice(existing, "platform")
|
||||
type_choice = _filter_get_choice(existing, "type")
|
||||
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
||||
price_min, price_max = _parse_range(existing, "price")
|
||||
price = _parse_number(existing, "price")
|
||||
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
|
||||
infinite_value = _parse_bool_nullable(existing, "infinite")
|
||||
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
|
||||
@@ -1250,25 +985,7 @@ def _purchase_fields(existing: dict) -> list:
|
||||
)
|
||||
date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased")
|
||||
date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded")
|
||||
|
||||
try:
|
||||
price_aggregate = Purchase.objects.aggregate(
|
||||
price_min=models.Min("price"), price_max=models.Max("price")
|
||||
)
|
||||
price_range_min = int(price_aggregate.get("price_min") or 0)
|
||||
price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
|
||||
except Exception:
|
||||
price_range_min, price_range_max = 0, 100
|
||||
|
||||
num_min, num_max = _parse_range(existing, "num_purchases")
|
||||
try:
|
||||
num_aggregate = Purchase.objects.aggregate(
|
||||
num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases")
|
||||
)
|
||||
num_range_min = max(int(num_aggregate.get("num_min") or 0), 0)
|
||||
num_range_max = max(int(num_aggregate.get("num_max") or 10), 1)
|
||||
except Exception:
|
||||
num_range_min, num_range_max = 0, 10
|
||||
num = _parse_number(existing, "num_purchases")
|
||||
|
||||
fields = [
|
||||
Div(
|
||||
@@ -1359,29 +1076,25 @@ def _purchase_fields(existing: dict) -> list:
|
||||
),
|
||||
_filter_field(
|
||||
"Price",
|
||||
RangeSlider(
|
||||
label="Price",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-price",
|
||||
min_value=price_min,
|
||||
max_value=price_max,
|
||||
range_min=price_range_min,
|
||||
range_max=price_range_max,
|
||||
min_placeholder="0.00",
|
||||
max_placeholder="100.00",
|
||||
value=price.value,
|
||||
value2=price.value2,
|
||||
modifier=price.modifier,
|
||||
placeholder="0.00",
|
||||
placeholder2="100.00",
|
||||
step="0.01",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Games in purchase",
|
||||
RangeSlider(
|
||||
label="Games in purchase",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-num-purchases",
|
||||
min_value=num_min,
|
||||
max_value=num_max,
|
||||
range_min=num_range_min,
|
||||
range_max=num_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
value=num.value,
|
||||
value2=num.value2,
|
||||
modifier=num.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 5",
|
||||
),
|
||||
),
|
||||
Div(
|
||||
@@ -1488,7 +1201,7 @@ class PlayEventFilterBar(_FilterBarBase):
|
||||
|
||||
def _playevent_fields(existing: dict) -> list:
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||
days = _parse_number(existing, "days_to_finish")
|
||||
started_min, started_max = _parse_range(existing, "started")
|
||||
ended_min, ended_max = _parse_range(existing, "ended")
|
||||
|
||||
@@ -1527,16 +1240,13 @@ def _playevent_fields(existing: dict) -> list:
|
||||
),
|
||||
_filter_field(
|
||||
"Days to Finish",
|
||||
RangeSlider(
|
||||
label="Days to Finish",
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-days-to-finish",
|
||||
min_value=days_min,
|
||||
max_value=days_max,
|
||||
range_min=0,
|
||||
range_max=365,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 30",
|
||||
value=days.value,
|
||||
value2=days.value2,
|
||||
modifier=days.modifier,
|
||||
placeholder="e.g. 1",
|
||||
placeholder2="e.g. 30",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1617,3 +1327,108 @@ def StringFilter(
|
||||
Input(attributes=input_attrs),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# text-sm + px-3 py-2.5 match every other input (canonical size).
|
||||
_NUMBER_FILTER_INPUT_CLASS = (
|
||||
"w-full rounded-base border border-default-medium px-3 py-2.5 text-sm "
|
||||
"bg-neutral-secondary-medium text-body focus:border-brand focus:ring-brand "
|
||||
)
|
||||
|
||||
|
||||
def NumberFilter(
|
||||
input_name_prefix: str,
|
||||
value: str = "",
|
||||
value2: str = "",
|
||||
modifier: str = "EQUALS",
|
||||
placeholder: str = "",
|
||||
placeholder2: str = "",
|
||||
step: str = "1",
|
||||
) -> Node:
|
||||
"""Renders a numeric filter with 8 modifier radio options and two inputs.
|
||||
|
||||
Modeled 1:1 on :func:`StringFilter`. Both inputs are disabled for the
|
||||
presence modifiers (IS_NULL/NOT_NULL); the second input is shown only for
|
||||
the range modifiers (BETWEEN/NOT_BETWEEN). Initial state is server-rendered
|
||||
so the widget never flashes before its JS runs.
|
||||
"""
|
||||
from common.criteria import Modifier
|
||||
|
||||
if modifier not in [m.value for m in Modifier.for_numbers()]:
|
||||
modifier = "EQUALS"
|
||||
|
||||
options = [
|
||||
("EQUALS", "is"),
|
||||
("NOT_EQUALS", "is not"),
|
||||
("GREATER_THAN", "is greater than"),
|
||||
("LESS_THAN", "is less than"),
|
||||
("BETWEEN", "between"),
|
||||
("NOT_BETWEEN", "not between"),
|
||||
("IS_NULL", "is null"),
|
||||
("NOT_NULL", "is not null"),
|
||||
]
|
||||
|
||||
radio_buttons = [
|
||||
Radio(
|
||||
name=f"{input_name_prefix}-modifier",
|
||||
label=lbl,
|
||||
checked=(modifier == mod_val),
|
||||
value=mod_val,
|
||||
attributes=[
|
||||
("data-number-modifier-radio", ""),
|
||||
],
|
||||
)
|
||||
for mod_val, lbl in options
|
||||
]
|
||||
|
||||
inputs_disabled = modifier in ("IS_NULL", "NOT_NULL")
|
||||
second_shown = modifier in ("BETWEEN", "NOT_BETWEEN")
|
||||
disabled_class = "opacity-50 cursor-not-allowed" if inputs_disabled else ""
|
||||
|
||||
value_attrs = [
|
||||
("name", input_name_prefix),
|
||||
("value", value if not inputs_disabled else ""),
|
||||
("placeholder", placeholder),
|
||||
("step", step),
|
||||
("class", _NUMBER_FILTER_INPUT_CLASS + disabled_class),
|
||||
]
|
||||
if inputs_disabled:
|
||||
value_attrs.append(("disabled", "true"))
|
||||
|
||||
value2_attrs = [
|
||||
("name", f"{input_name_prefix}-value2"),
|
||||
("value", value2 if not inputs_disabled else ""),
|
||||
("placeholder", placeholder2),
|
||||
("step", step),
|
||||
("data-number-value2", ""),
|
||||
(
|
||||
"class",
|
||||
_NUMBER_FILTER_INPUT_CLASS
|
||||
+ disabled_class
|
||||
+ ("" if second_shown else " hidden"),
|
||||
),
|
||||
]
|
||||
if inputs_disabled:
|
||||
value2_attrs.append(("disabled", "true"))
|
||||
|
||||
return Div(
|
||||
attributes=[("class", "flex flex-col gap-2 @container")],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"grid grid-cols-2 @md:grid-cols-4 gap-2 py-1",
|
||||
)
|
||||
],
|
||||
children=radio_buttons,
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-2")],
|
||||
children=[
|
||||
Input(type="number", attributes=value_attrs),
|
||||
Input(type="number", attributes=value2_attrs),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<head>
|
||||
<title>Boolean filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||
</head>
|
||||
|
||||
@@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<head>
|
||||
<title>Date filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||
</head>
|
||||
|
||||
@@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<head>
|
||||
<title>Date range picker E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/date-range-picker.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""End-to-end Playwright test for the Stash-style NumberFilter: modifier
|
||||
serialization, the between second-input reveal, null-state toggling, and prefill.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import FilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Number filter E2E</title>
|
||||
<link rel="stylesheet" href="/static/base.css">
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"year_released": {"value": 2000, "value2": 2010, "modifier": "BETWEEN"},
|
||||
"session_count": {"modifier": "IS_NULL"},
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-number-filter-empty/", empty_bar_view),
|
||||
path("test-number-filter-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def _open(page, url):
|
||||
"""Navigate to the bar page and expand the collapsed filter body.
|
||||
|
||||
base.css is loaded so the `hidden` Tailwind class actually hides elements
|
||||
(needed to assert the value2 reveal) — which means the bar starts collapsed
|
||||
and must be opened before its inputs are interactable."""
|
||||
page.goto(url)
|
||||
page.locator("[data-filter-bar-toggle]").click()
|
||||
|
||||
|
||||
def _submit(page):
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
|
||||
def test_number_filter_defaults_and_greater_than(live_server, page):
|
||||
_open(page, live_server.url + "/test-number-filter-empty/")
|
||||
|
||||
value_input = page.locator('input[name="filter-year"]')
|
||||
value2_input = page.locator('input[name="filter-year-value2"]')
|
||||
assert value_input.is_enabled()
|
||||
# EQUALS is the default; the second input is hidden.
|
||||
assert page.locator(
|
||||
'input[name="filter-year-modifier"][value="EQUALS"]'
|
||||
).is_checked()
|
||||
assert value2_input.is_hidden()
|
||||
|
||||
value_input.fill("2015")
|
||||
page.locator('input[name="filter-year-modifier"][value="GREATER_THAN"]').click()
|
||||
_submit(page)
|
||||
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["year_released"] == {"value": 2015, "modifier": "GREATER_THAN"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
|
||||
def test_number_filter_between_reveals_and_serializes(live_server, page):
|
||||
_open(page, live_server.url + "/test-number-filter-empty/")
|
||||
|
||||
value2_input = page.locator('input[name="filter-year-value2"]')
|
||||
assert value2_input.is_hidden()
|
||||
|
||||
page.locator('input[name="filter-year-modifier"][value="BETWEEN"]').click()
|
||||
assert value2_input.is_visible()
|
||||
|
||||
page.locator('input[name="filter-year"]').fill("2000")
|
||||
value2_input.fill("2010")
|
||||
_submit(page)
|
||||
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["year_released"] == {
|
||||
"value": 2000,
|
||||
"value2": 2010,
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
|
||||
def test_number_filter_null_states(live_server, page):
|
||||
_open(page, live_server.url + "/test-number-filter-empty/")
|
||||
|
||||
value_input = page.locator('input[name="filter-year"]')
|
||||
value_input.fill("1999")
|
||||
|
||||
page.locator('input[name="filter-year-modifier"][value="IS_NULL"]').click()
|
||||
|
||||
# Both inputs disable and clear under a presence modifier.
|
||||
assert not value_input.is_enabled()
|
||||
assert value_input.input_value() == ""
|
||||
|
||||
_submit(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["year_released"] == {"modifier": "IS_NULL"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
|
||||
def test_number_filter_prefilled_states(live_server, page):
|
||||
_open(page, live_server.url + "/test-number-filter-prefilled/")
|
||||
|
||||
# year_released: BETWEEN with both bounds, second input visible.
|
||||
assert page.locator('input[name="filter-year"]').input_value() == "2000"
|
||||
assert page.locator('input[name="filter-year-value2"]').input_value() == "2010"
|
||||
assert page.locator('input[name="filter-year-value2"]').is_visible()
|
||||
assert page.locator(
|
||||
'input[name="filter-year-modifier"][value="BETWEEN"]'
|
||||
).is_checked()
|
||||
|
||||
# session_count: IS_NULL — value input disabled, modifier checked.
|
||||
session_input = page.locator('input[name="filter-session-count"]')
|
||||
assert not session_input.is_enabled()
|
||||
assert page.locator(
|
||||
'input[name="filter-session-count-modifier"][value="IS_NULL"]'
|
||||
).is_checked()
|
||||
@@ -1,114 +0,0 @@
|
||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import FilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Range Slider E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-range-slider/", empty_bar_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# 1. Start with known state: Min is empty, Max is empty
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 2. Type "20" into max input
|
||||
max_input.fill("20")
|
||||
|
||||
# 3. Type "50" into min input (which is higher than 20)
|
||||
min_input.fill("50")
|
||||
|
||||
# 4. Max input should have automatically synchronized/snapped to 50
|
||||
assert max_input.input_value() == "50"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 1. Type "50" into min input
|
||||
min_input.fill("50")
|
||||
|
||||
# 2. Type "30" into max input (which is less than 50)
|
||||
max_input.fill("30")
|
||||
|
||||
# 3. Min input should have automatically synchronized/snapped to 30
|
||||
assert min_input.input_value() == "30"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||
max_input.fill("150")
|
||||
max_input.blur() # triggers "change" event
|
||||
|
||||
assert max_input.input_value() == "100"
|
||||
|
||||
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||
min_input.fill("-20")
|
||||
min_input.blur() # triggers "change" event
|
||||
|
||||
assert min_input.input_value() == "0"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# Locate handles
|
||||
max_handle = page.locator(
|
||||
'.range-handle-max[data-target="filter-session-count-max"]'
|
||||
)
|
||||
|
||||
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||
style = max_handle.get_attribute("style")
|
||||
assert "left:100%" in style or "left: 100%" in style
|
||||
|
||||
# Set min to 50
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
min_input.fill("50")
|
||||
|
||||
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
||||
style = max_handle.get_attribute("style")
|
||||
assert "left:100%" in style or "left: 100%" in style
|
||||
@@ -17,7 +17,6 @@ def _bar_page(filter_json: str = "") -> str:
|
||||
<head>
|
||||
<title>String filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
|
||||
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
|
||||
</head>
|
||||
|
||||
+15
-15
@@ -1,4 +1,4 @@
|
||||
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
|
||||
"""Browser tests for widget JavaScript (search_select.js, filter-bar.js,
|
||||
add_purchase.js) and their onSwap() initialization lifecycle.
|
||||
|
||||
These run a real Chromium via pytest-playwright against pytest-django's
|
||||
@@ -70,21 +70,21 @@ def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
|
||||
expect(pill).to_contain_text("Finished")
|
||||
|
||||
|
||||
def test_range_slider_mode_toggle_fires_exactly_once(
|
||||
def test_number_filter_between_reveals_second_input(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""One click on the mode toggle flips the slider from range to point mode
|
||||
exactly once. Double-bound listeners (the old force-re-init bug) would
|
||||
flip it twice, leaving data-mode unchanged."""
|
||||
"""Selecting the BETWEEN modifier on a NumberFilter reveals its second
|
||||
(value2) input — proof that setupNumberFilters wired the modifier radios on
|
||||
the initial page load."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
slider = page.locator("range-slider").first
|
||||
expect(slider).to_have_attribute("mode", "range")
|
||||
value2 = page.locator('input[name="filter-year-value2"]')
|
||||
expect(value2).to_be_hidden()
|
||||
|
||||
slider.locator(".range-mode-toggle").click()
|
||||
expect(slider).to_have_attribute("mode", "point")
|
||||
page.locator('input[name="filter-year-modifier"][value="BETWEEN"]').check()
|
||||
expect(value2).to_be_visible()
|
||||
|
||||
|
||||
def test_widgets_initialize_inside_htmx_swapped_content(
|
||||
@@ -94,8 +94,8 @@ def test_widgets_initialize_inside_htmx_swapped_content(
|
||||
|
||||
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
|
||||
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
|
||||
swapped-in slider must toggle exactly once, proving the htmx:load half of
|
||||
onSwap and the once-per-element guard."""
|
||||
swapped-in NumberFilter must reveal its second input on BETWEEN, proving the
|
||||
htmx:load half of onSwap and the once-per-element guard."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
@@ -111,10 +111,10 @@ def test_widgets_initialize_inside_htmx_swapped_content(
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
expect(widget.locator("[data-search-select-options]")).to_be_visible()
|
||||
|
||||
slider = page.locator("range-slider").first
|
||||
expect(slider).to_have_attribute("mode", "range")
|
||||
slider.locator(".range-mode-toggle").click()
|
||||
expect(slider).to_have_attribute("mode", "point")
|
||||
value2 = page.locator('input[name="filter-year-value2"]')
|
||||
expect(value2).to_be_hidden()
|
||||
page.locator('input[name="filter-year-modifier"][value="BETWEEN"]').check()
|
||||
expect(value2).to_be_visible()
|
||||
|
||||
|
||||
def test_add_purchase_type_toggles_disabled_fields(
|
||||
|
||||
+102
-36
@@ -1,11 +1,9 @@
|
||||
"""Characterization tests locking the rendered output of the three filter bars.
|
||||
|
||||
The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the
|
||||
target of a dedup + module split + RangeSlider component extraction. These tests
|
||||
pin the structural contract — form/input ids, the hidden ``filter`` field,
|
||||
preset wiring, the filter_json round-trip, no double-escaping, and the
|
||||
Flowbite-styled native range slider unification — so that refactor stays
|
||||
behaviour-preserving.
|
||||
The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) pins the
|
||||
structural contract — form/input ids, the hidden ``filter`` field, preset wiring,
|
||||
the filter_json round-trip, no double-escaping, and the Stash-style NumberFilter /
|
||||
StringFilter modifier widgets — so refactors stay behaviour-preserving.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -14,6 +12,7 @@ from django.test import TestCase
|
||||
|
||||
from common.components import (
|
||||
FilterBar,
|
||||
NumberFilter,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
)
|
||||
@@ -41,23 +40,20 @@ class FilterBarRenderingTest(TestCase):
|
||||
self.assertIn(save_url, html) # preset save URL wired in
|
||||
self.assertNoEscapedTags(html)
|
||||
|
||||
def _assert_range_slider(self, html):
|
||||
"""Every filter bar must use the RangeSlider component with custom
|
||||
draggable <div> handles, a track fill, and mode-toggle button."""
|
||||
self.assertIn("<range-slider", html)
|
||||
self.assertIn('mode="range"', html)
|
||||
self.assertIn("range-mode-toggle", html)
|
||||
self.assertIn("range-mode-icon-range", html)
|
||||
self.assertIn("range-mode-icon-point", html)
|
||||
self.assertIn("range-track-fill", html)
|
||||
self.assertIn("range-handle-min", html)
|
||||
self.assertIn("range-handle-max", html)
|
||||
# No native range inputs
|
||||
self.assertNotIn(
|
||||
'<input type="range"',
|
||||
html,
|
||||
"native <input type=range> found — should use custom div handles",
|
||||
)
|
||||
def _assert_number_filter(self, html, field_prefix):
|
||||
"""Every filter bar must use the Stash-style NumberFilter: a modifier
|
||||
radio grid plus two number inputs (value + value2), with no legacy
|
||||
RangeSlider custom element left behind."""
|
||||
self.assertIn("data-number-modifier-radio", html)
|
||||
self.assertIn(f'name="{field_prefix}-modifier"', html)
|
||||
self.assertIn('value="BETWEEN"', html)
|
||||
self.assertIn('value="IS_NULL"', html)
|
||||
self.assertIn(f'name="{field_prefix}"', html)
|
||||
self.assertIn(f'name="{field_prefix}-value2"', html)
|
||||
self.assertIn("data-number-value2", html)
|
||||
# The old slider element must be fully gone.
|
||||
self.assertNotIn("<range-slider", html)
|
||||
self.assertNotIn("range-mode-toggle", html)
|
||||
|
||||
def test_game_filter_bar(self):
|
||||
html = str(
|
||||
@@ -68,7 +64,7 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/games/list", "/presets/games/save")
|
||||
self._assert_range_slider(html)
|
||||
self._assert_number_filter(html, "filter-year")
|
||||
|
||||
def test_session_filter_bar(self):
|
||||
html = str(
|
||||
@@ -79,7 +75,7 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save")
|
||||
self._assert_range_slider(html)
|
||||
self._assert_number_filter(html, "filter-duration-total-hours")
|
||||
|
||||
def test_purchase_filter_bar(self):
|
||||
html = str(
|
||||
@@ -90,7 +86,7 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
||||
self._assert_range_slider(html)
|
||||
self._assert_number_filter(html, "filter-price")
|
||||
|
||||
def test_purchase_filter_bar_games_has_m2m_modifiers(self):
|
||||
"""The many-to-many games field surfaces (All)/(Only) pseudo-options
|
||||
@@ -277,8 +273,8 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
|
||||
def test_playevent_filter_bar_labels_days_to_finish_slider(self):
|
||||
"""The Days to Finish range slider must be wrapped in a labelled field —
|
||||
RangeSlider does not render its own label, so a bare slider shows none."""
|
||||
"""The Days to Finish NumberFilter must be wrapped in a labelled field —
|
||||
NumberFilter does not render its own label, so a bare widget shows none."""
|
||||
from common.components import PlayEventFilterBar
|
||||
|
||||
html = str(
|
||||
@@ -309,14 +305,16 @@ class FilterBarRenderingTest(TestCase):
|
||||
# Free-text widget for playevent notes (now StringFilter)
|
||||
self.assertIn('name="filter-playevent_note"', html)
|
||||
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||
# New range slider input prefixes
|
||||
self.assertIn('name="filter-purchase-count-min"', html)
|
||||
self.assertIn('name="filter-playevent-count-min"', html)
|
||||
self.assertIn('name="filter-manual-playtime-hours-min"', html)
|
||||
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
|
||||
self.assertIn('name="filter-original-year-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-total-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||
# New NumberFilter input prefixes (value input named by bare prefix)
|
||||
self.assertIn('name="filter-purchase-count"', html)
|
||||
self.assertIn('name="filter-purchase-count-value2"', html)
|
||||
self.assertIn('name="filter-playevent-count"', html)
|
||||
self.assertIn('name="filter-manual-playtime-hours"', html)
|
||||
self.assertIn('name="filter-calculated-playtime-hours"', html)
|
||||
self.assertIn('name="filter-original-year"', html)
|
||||
self.assertIn('name="filter-purchase-price-total"', html)
|
||||
self.assertIn('name="filter-purchase-price-any"', html)
|
||||
self.assertIn('name="filter-purchase-count-modifier"', html)
|
||||
# New boolean checkboxes
|
||||
self.assertIn('name="filter-purchase-refunded"', html)
|
||||
self.assertIn('name="filter-purchase-infinite"', html)
|
||||
@@ -428,3 +426,71 @@ class FilterBarRenderingTest(TestCase):
|
||||
self.assertIn('name="filter-refunded"', purchase_html)
|
||||
self.assertIn('value="true"', purchase_html)
|
||||
self.assertIn('value="false"', purchase_html)
|
||||
|
||||
|
||||
class NumberFilterRenderTest(TestCase):
|
||||
"""Render-level contract for the Stash-style NumberFilter component."""
|
||||
|
||||
def test_renders_all_eight_modifier_radios(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year"))
|
||||
for modifier in (
|
||||
"EQUALS",
|
||||
"NOT_EQUALS",
|
||||
"GREATER_THAN",
|
||||
"LESS_THAN",
|
||||
"BETWEEN",
|
||||
"NOT_BETWEEN",
|
||||
"IS_NULL",
|
||||
"NOT_NULL",
|
||||
):
|
||||
self.assertIn(f'value="{modifier}"', html)
|
||||
self.assertIn("data-number-modifier-radio", html)
|
||||
|
||||
def test_renders_two_number_inputs(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year"))
|
||||
self.assertIn('type="number"', html)
|
||||
self.assertIn('name="filter-year"', html)
|
||||
self.assertIn('name="filter-year-value2"', html)
|
||||
self.assertIn("data-number-value2", html)
|
||||
|
||||
def test_default_modifier_hides_second_input_and_enables_inputs(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year"))
|
||||
# value2 is hidden for the default EQUALS modifier.
|
||||
self.assertRegex(html, r'data-number-value2="" class="[^"]*\bhidden\b')
|
||||
# Inputs are not disabled by default.
|
||||
self.assertNotIn("disabled", html)
|
||||
|
||||
def test_between_shows_second_input_and_prefills_values(self):
|
||||
html = str(
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-year",
|
||||
value="2000",
|
||||
value2="2010",
|
||||
modifier="BETWEEN",
|
||||
)
|
||||
)
|
||||
self.assertIn('value="2000"', html)
|
||||
self.assertIn('value="2010"', html)
|
||||
# The second input must NOT carry the hidden class under BETWEEN.
|
||||
self.assertNotRegex(html, r'data-number-value2="" class="[^"]*\bhidden\b')
|
||||
|
||||
def test_presence_modifier_disables_and_clears_inputs(self):
|
||||
html = str(
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-year",
|
||||
value="2000",
|
||||
value2="2010",
|
||||
modifier="IS_NULL",
|
||||
)
|
||||
)
|
||||
self.assertIn("disabled", html)
|
||||
self.assertIn("cursor-not-allowed", html)
|
||||
# Values are cleared while disabled.
|
||||
self.assertNotIn('value="2000"', html)
|
||||
self.assertNotIn('value="2010"', html)
|
||||
|
||||
def test_invalid_modifier_falls_back_to_equals(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year", modifier="INCLUDES"))
|
||||
# EQUALS is the only checked radio when an invalid modifier is given.
|
||||
self.assertRegex(html, r'value="EQUALS"[^>]*checked="true"')
|
||||
self.assertNotRegex(html, r'value="INCLUDES"')
|
||||
|
||||
@@ -74,6 +74,45 @@ class TestIntCriterion:
|
||||
year_released__gte=2020, year_released__lte=2024
|
||||
)
|
||||
|
||||
def test_not_between(self):
|
||||
c = IntCriterion(value=2020, value2=2024, modifier=Modifier.NOT_BETWEEN)
|
||||
assert c.to_q("year_released") == Q(year_released__lt=2020) | Q(
|
||||
year_released__gt=2024
|
||||
)
|
||||
|
||||
def test_greater_than(self):
|
||||
c = IntCriterion(value=10, modifier=Modifier.GREATER_THAN)
|
||||
assert c.to_q("session_count") == Q(session_count__gt=10)
|
||||
|
||||
def test_less_than(self):
|
||||
c = IntCriterion(value=10, modifier=Modifier.LESS_THAN)
|
||||
assert c.to_q("session_count") == Q(session_count__lt=10)
|
||||
|
||||
def test_is_null(self):
|
||||
c = IntCriterion(modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("year_released") == Q(year_released__isnull=True)
|
||||
|
||||
def test_not_null(self):
|
||||
c = IntCriterion(modifier=Modifier.NOT_NULL)
|
||||
assert c.to_q("year_released") == Q(year_released__isnull=False)
|
||||
|
||||
def test_round_trip_json_between(self):
|
||||
"""value/value2/modifier survive dict → dataclass → dict unchanged."""
|
||||
original = IntCriterion(value=2020, value2=2024, modifier=Modifier.BETWEEN)
|
||||
as_dict = original.to_json()
|
||||
assert as_dict == {
|
||||
"value": 2020,
|
||||
"value2": 2024,
|
||||
"modifier": Modifier.BETWEEN,
|
||||
}
|
||||
assert IntCriterion.from_json(as_dict) == original
|
||||
|
||||
def test_round_trip_json_is_null(self):
|
||||
original = IntCriterion(modifier=Modifier.IS_NULL)
|
||||
restored = IntCriterion.from_json(original.to_json())
|
||||
assert restored == original
|
||||
assert restored.to_q("year_released") == Q(year_released__isnull=True)
|
||||
|
||||
|
||||
class TestBoolCriterion:
|
||||
def test_equals_true(self):
|
||||
|
||||
+3
-14
@@ -153,27 +153,16 @@ class RealComponentMediaTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(media.js, ("dist/elements/date-range-picker.js",))
|
||||
|
||||
def test_range_slider_declares_its_script(self):
|
||||
from common.components.filters import RangeSlider
|
||||
|
||||
media = collect_media(
|
||||
RangeSlider(
|
||||
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
|
||||
)
|
||||
)
|
||||
self.assertEqual(media.js, ("dist/elements/range-slider.js",))
|
||||
|
||||
def test_filter_bar_collects_chrome_and_widget_media(self):
|
||||
"""A FilterBar's media merges its own chrome script with the scripts that
|
||||
bubble up from the FilterSelect and RangeSlider widgets it contains —
|
||||
exactly the set the view used to thread by hand. (FilterBar wraps its DB
|
||||
aggregates in try/except, so it builds without a database.)"""
|
||||
bubble up from the FilterSelect widgets it contains — exactly the set the
|
||||
view used to thread by hand. (NumberFilter/StringFilter declare no media;
|
||||
their behavior lives in the always-present filter-bar element.)"""
|
||||
from common.components import FilterBar
|
||||
|
||||
media = collect_media(FilterBar())
|
||||
self.assertIn("dist/elements/filter-bar.js", media.js)
|
||||
self.assertIn("dist/elements/search-select.js", media.js)
|
||||
self.assertIn("dist/elements/range-slider.js", media.js)
|
||||
|
||||
|
||||
class HtpyStyleSugarTest(unittest.TestCase):
|
||||
|
||||
@@ -66,7 +66,6 @@ class RenderedPagesTest(TestCase):
|
||||
html = self.get("games:list_games").content.decode()
|
||||
self.assertIn("js/dist/elements/filter-bar.js", html)
|
||||
self.assertIn("js/dist/elements/search-select.js", html)
|
||||
self.assertIn("js/dist/elements/range-slider.js", html)
|
||||
|
||||
def test_stats_page_auto_loads_datepicker(self):
|
||||
"""YearPicker declares the datepicker UMD bundle as media; the stats
|
||||
|
||||
+60
-25
@@ -23,13 +23,6 @@ interface DeselectableRadio extends HTMLInputElement {
|
||||
wasChecked?: boolean;
|
||||
}
|
||||
|
||||
interface RangeField {
|
||||
prefix: string;
|
||||
key: string;
|
||||
ignoreZeroZero?: boolean;
|
||||
convert?: (value: number) => number;
|
||||
}
|
||||
|
||||
function criterion(value: unknown, value2: unknown, modifier: string): Criterion {
|
||||
const result: Criterion = { value, modifier };
|
||||
if (value2 !== null && value2 !== undefined && value2 !== "") {
|
||||
@@ -166,7 +159,7 @@ function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
|
||||
}
|
||||
});
|
||||
|
||||
const rangeFields: RangeField[] = [
|
||||
const numberFields = [
|
||||
{ prefix: "filter-year", key: "year_released" },
|
||||
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||
{ prefix: "filter-session-count", key: "session_count" },
|
||||
@@ -183,19 +176,25 @@ function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
|
||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true },
|
||||
{ prefix: "filter-playtime-hours", key: "playtime_hours" },
|
||||
];
|
||||
|
||||
rangeFields.forEach((rangeField) => {
|
||||
let valueMin = numberValue(form, rangeField.prefix + "-min");
|
||||
let valueMax = numberValue(form, rangeField.prefix + "-max");
|
||||
if (rangeField.convert) {
|
||||
if (valueMin !== "") valueMin = rangeField.convert(valueMin);
|
||||
if (valueMax !== "") valueMax = rangeField.convert(valueMax);
|
||||
numberFields.forEach((numberField) => {
|
||||
const modifierElement = form.querySelector<HTMLInputElement>(
|
||||
`[name="${numberField.prefix}-modifier"]:checked`,
|
||||
);
|
||||
const modifier = modifierElement ? modifierElement.value : "EQUALS";
|
||||
if (modifier === "IS_NULL" || modifier === "NOT_NULL") {
|
||||
filter[numberField.key] = { modifier };
|
||||
return;
|
||||
}
|
||||
if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) return;
|
||||
const result = buildRangeCriterion(valueMin, valueMax);
|
||||
if (result !== null) filter[rangeField.key] = result;
|
||||
const value = numberValue(form, numberField.prefix);
|
||||
if (modifier === "BETWEEN" || modifier === "NOT_BETWEEN") {
|
||||
const value2 = numberValue(form, numberField.prefix + "-value2");
|
||||
if (value !== "") filter[numberField.key] = criterion(value, value2, modifier);
|
||||
return;
|
||||
}
|
||||
if (value !== "") filter[numberField.key] = criterion(value, null, modifier);
|
||||
});
|
||||
|
||||
const dateRangeFields = [
|
||||
@@ -279,13 +278,48 @@ function toggleStringFilterInput(radio: HTMLInputElement): void {
|
||||
}
|
||||
|
||||
function setupStringFilters(root: HTMLElement): void {
|
||||
root
|
||||
.querySelectorAll<HTMLInputElement>("input[data-string-modifier-radio]")
|
||||
.forEach((radio) => {
|
||||
radio.addEventListener("change", function (this: HTMLInputElement) {
|
||||
toggleStringFilterInput(this);
|
||||
});
|
||||
});
|
||||
// Delegated on the persistent custom element (see setupNumberFilters) so the
|
||||
// modifier radios keep working after an htmx swap of the inner #filter-bar.
|
||||
root.addEventListener("change", (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.matches("input[data-string-modifier-radio]")) {
|
||||
toggleStringFilterInput(target as HTMLInputElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNumberFilterInput(radio: HTMLInputElement): void {
|
||||
const container = radio.closest(".flex-col");
|
||||
if (!container) return;
|
||||
const inputs = container.querySelectorAll<HTMLInputElement>('input[type="number"]');
|
||||
const value2 = container.querySelector<HTMLInputElement>("[data-number-value2]");
|
||||
const checkedRadio = container.querySelector<HTMLInputElement>('input[type="radio"]:checked');
|
||||
const modifier = checkedRadio ? checkedRadio.value : "";
|
||||
const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
||||
const isBetween = modifier === "BETWEEN" || modifier === "NOT_BETWEEN";
|
||||
inputs.forEach((input) => {
|
||||
if (isPresence) {
|
||||
input.disabled = true;
|
||||
input.value = "";
|
||||
input.classList.add("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
input.disabled = false;
|
||||
input.classList.remove("opacity-50", "cursor-not-allowed");
|
||||
}
|
||||
});
|
||||
if (value2) value2.classList.toggle("hidden", isPresence || !isBetween);
|
||||
}
|
||||
|
||||
function setupNumberFilters(root: HTMLElement): void {
|
||||
// Delegated on the persistent custom element so the modifier radios keep
|
||||
// working after the inner #filter-bar body is htmx-swapped (connectedCallback
|
||||
// does not re-run for inner swaps — a direct per-radio listener would be lost).
|
||||
root.addEventListener("change", (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.matches("input[data-number-modifier-radio]")) {
|
||||
toggleNumberFilterInput(target as HTMLInputElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupPresetDeleteHandlers(container: HTMLElement): void {
|
||||
@@ -442,6 +476,7 @@ class FilterBarElement extends HTMLElement {
|
||||
injectSearchInput(form);
|
||||
setupDeselectableRadios(this);
|
||||
setupStringFilters(this);
|
||||
setupNumberFilters(this);
|
||||
if (presetListUrl) loadPresets(this, presetListUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* Range slider — custom draggable handles (no native <input type=range>).
|
||||
*
|
||||
* Supports two modes, toggled via the .range-mode-toggle button:
|
||||
* range (default) — two handles, min ≤ max constraint
|
||||
* point — single handle, sets both number inputs to the same value
|
||||
*
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes on the handles).
|
||||
* Behavior is wired in connectedCallback; the typed props (min, max, step, mode)
|
||||
* come from the server via readRangeSliderProps.
|
||||
*/
|
||||
import { readRangeSliderProps } from "../generated/props.js";
|
||||
|
||||
class RangeSliderElement extends HTMLElement {
|
||||
private onMouseMove: ((event: MouseEvent) => void) | null = null;
|
||||
private onMouseUp: (() => void) | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
const { min: dataMin, max: dataMax, step, mode: initialMode } =
|
||||
readRangeSliderProps(this);
|
||||
let mode = initialMode;
|
||||
|
||||
const track = this.querySelector<HTMLElement>("[data-range-track]");
|
||||
const trackFill = this.querySelector<HTMLElement>(".range-track-fill");
|
||||
const minHandle = this.querySelector<HTMLElement>(".range-handle-min");
|
||||
const maxHandle = this.querySelector<HTMLElement>(".range-handle-max");
|
||||
if (!track || !minHandle || !maxHandle) return;
|
||||
|
||||
const minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target") ?? ""
|
||||
) as HTMLInputElement | null;
|
||||
const maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target") ?? ""
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function valueToPercent(value: number): number {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent: number): number {
|
||||
const raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value: number, low: number, high: number): number {
|
||||
return Math.max(low, Math.min(high, value));
|
||||
}
|
||||
|
||||
function getTargetValue(
|
||||
target: HTMLInputElement | null,
|
||||
defaultValue: number
|
||||
): number {
|
||||
if (!target || target.value === "") return defaultValue;
|
||||
const parsed = parseInt(target.value, 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
function setTargetValue(
|
||||
target: HTMLInputElement | null,
|
||||
value: number | string
|
||||
): void {
|
||||
if (target) target.value = String(value);
|
||||
}
|
||||
|
||||
// ── Track fill positioning ──
|
||||
|
||||
function updateTrackFill(): void {
|
||||
if (!trackFill) return;
|
||||
const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxValue) + "%";
|
||||
} else {
|
||||
let leftPercent = valueToPercent(minValue);
|
||||
let rightPercent = valueToPercent(maxValue);
|
||||
if (leftPercent > rightPercent) {
|
||||
const temp = leftPercent;
|
||||
leftPercent = rightPercent;
|
||||
rightPercent = temp;
|
||||
}
|
||||
const widthPercent = rightPercent - leftPercent;
|
||||
trackFill.style.left = leftPercent + "%";
|
||||
trackFill.style.width = widthPercent + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHandles(): void {
|
||||
const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
minHandle!.style.left = valueToPercent(minValue) + "%";
|
||||
maxHandle!.style.left = valueToPercent(maxValue) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
|
||||
// ── Dragging ──
|
||||
|
||||
const makeDraggable = (handle: HTMLElement, isMin: boolean): void => {
|
||||
handle.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = track.getBoundingClientRect();
|
||||
|
||||
const onMove = (moveEvent: MouseEvent): void => {
|
||||
const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
|
||||
const value = percentToValue(clamp(percent, 0, 100));
|
||||
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget) minTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||
);
|
||||
if (minTarget) minTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
} else {
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||
);
|
||||
if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
updateHandles();
|
||||
};
|
||||
|
||||
const onUp = (): void => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
this.onMouseMove = null;
|
||||
this.onMouseUp = null;
|
||||
};
|
||||
|
||||
this.onMouseMove = onMove;
|
||||
this.onMouseUp = onUp;
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(event);
|
||||
});
|
||||
};
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// ── Sync from number inputs back to handles ──
|
||||
|
||||
function syncFromInputs(event?: Event): void {
|
||||
if (mode === "point") {
|
||||
const source =
|
||||
(event?.target as HTMLInputElement | null) || minTarget || maxTarget;
|
||||
const value = source ? source.value : "";
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
} else if (event && event.target) {
|
||||
const minValue = getTargetValue(minTarget, dataMin);
|
||||
const maxValue = getTargetValue(maxTarget, dataMax);
|
||||
if (event.target === minTarget) {
|
||||
if (minValue > maxValue) {
|
||||
setTargetValue(maxTarget, minValue);
|
||||
}
|
||||
} else if (event.target === maxTarget) {
|
||||
if (maxValue < minValue) {
|
||||
setTargetValue(minTarget, maxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
function enforceStrictBounds(event: Event): void {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
if (target) {
|
||||
const value = parseInt(target.value, 10);
|
||||
if (!isNaN(value)) {
|
||||
const clamped = clamp(value, dataMin, dataMax);
|
||||
if (clamped !== value) {
|
||||
setTargetValue(target, clamped);
|
||||
target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minTarget) {
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
minTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
if (maxTarget) {
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
const toggleButton = this.querySelector<HTMLElement>(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", () => {
|
||||
const newMode = mode === "range" ? "point" : "range";
|
||||
this.setAttribute("mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
const iconRange = toggleButton.querySelector(".range-mode-icon-range");
|
||||
const iconPoint = toggleButton.querySelector(".range-mode-icon-point");
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
const dashSpan = this.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.onMouseMove) {
|
||||
document.removeEventListener("mousemove", this.onMouseMove);
|
||||
this.onMouseMove = null;
|
||||
}
|
||||
if (this.onMouseUp) {
|
||||
document.removeEventListener("mouseup", this.onMouseUp);
|
||||
this.onMouseUp = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("range-slider", RangeSliderElement);
|
||||
Reference in New Issue
Block a user