diff --git a/common/components/__init__.py b/common/components/__init__.py index f1a5659..e872fbe 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -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", ] diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index ff87988..5140f46 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -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 diff --git a/common/components/filters.py b/common/components/filters.py index e8527ea..db96bdb 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -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 = ( - '' - '' - '' - '' - "" -) - -_POINT_ICON_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 ```` 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), + ], + ), + ], + ) diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py index 15b86b2..d0721ed 100644 --- a/e2e/test_boolean_filter_e2e.py +++ b/e2e/test_boolean_filter_e2e.py @@ -22,7 +22,6 @@ def _bar_page(filter_json: str = "") -> str: Boolean filter E2E - diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py index dbdeb26..9e7a9da 100644 --- a/e2e/test_date_filter_e2e.py +++ b/e2e/test_date_filter_e2e.py @@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str: Date filter E2E - diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py index 95b1caa..0dc0c73 100644 --- a/e2e/test_date_range_picker_e2e.py +++ b/e2e/test_date_range_picker_e2e.py @@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str: Date range picker E2E - diff --git a/e2e/test_number_filter_e2e.py b/e2e/test_number_filter_e2e.py new file mode 100644 index 0000000..4ef4c2f --- /dev/null +++ b/e2e/test_number_filter_e2e.py @@ -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""" + + + Number filter E2E + + + + + + + {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} + +""" + + +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() diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py deleted file mode 100644 index 1285975..0000000 --- a/e2e/test_range_slider_e2e.py +++ /dev/null @@ -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""" - - - Range Slider E2E - - - - - - - {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} - -""" - - -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 diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py index dd0af39..7005ca6 100644 --- a/e2e/test_string_filter_e2e.py +++ b/e2e/test_string_filter_e2e.py @@ -17,7 +17,6 @@ def _bar_page(filter_json: str = "") -> str: String filter E2E - diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py index a240a97..fd598cc 100644 --- a/e2e/test_widgets_e2e.py +++ b/e2e/test_widgets_e2e.py @@ -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( diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index d2ba793..a178221 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -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
handles, a track fill, and mode-toggle button.""" - self.assertIn(" 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("