Align set-criterion modifiers with Stash (any/all/none) and harmonize EXCLUDES
Closes #10. Backend (common/criteria.py): - Treat `excludes` as an always-orthogonal AND'd negative across both MultiCriterion and ChoiceCriterion; the modifier now governs only the `value` (include) set. This removes the prior divergence where MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES swapped include/exclude into a positive. - Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q. M2M "has all" (games/filters.py): - PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so INCLUDES_ALL on the many-to-many games field works in a single .filter() (a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing). UI (FilterSelect + filter_bar.js): - Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/ EXCLUDES) rendered before the pills via a new `leading` slot on the shared combobox shell. A native control so its value is its state. readSearchSelect serialises it to data-match; filter_bar folds it into the criterion modifier. Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel. - Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful for multi-valued relations). Styled with already-compiled utilities. Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect / PurchaseFilterBar rendering + round-trip of the match mode. https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
This commit is contained in:
@@ -101,6 +101,14 @@ _FILTER_ACTION_BUTTON_CLASS = (
|
||||
_FILTER_MODIFIER_ROW_CLASS = (
|
||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
||||
)
|
||||
# The match-mode <select> (any/all/none → INCLUDES/INCLUDES_ALL/EXCLUDES). A
|
||||
# native control so its value *is* its state — no class toggling in the JS.
|
||||
# shrink-0 keeps it from collapsing as pills wrap; it sits before the pills.
|
||||
_FILTER_MATCH_SELECT_CLASS = (
|
||||
"shrink-0 rounded border border-default-medium bg-neutral-secondary-medium "
|
||||
"text-xs text-body px-2 py-0.5 cursor-pointer "
|
||||
"focus:ring-brand focus:border-brand"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_option(option) -> SearchSelectOption:
|
||||
@@ -159,6 +167,7 @@ def _combobox_shell(
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[SafeText] | None = None,
|
||||
leading: SafeText | None = None,
|
||||
) -> SafeText:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
|
||||
@@ -169,8 +178,9 @@ def _combobox_shell(
|
||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||
dynamically-added rows/pills). The shell knows nothing about how individual
|
||||
rows or pills look.
|
||||
dynamically-added rows/pills). An optional ``leading`` element is placed
|
||||
before the pills (e.g. the filter match-mode select). The shell knows nothing
|
||||
about how individual rows or pills look.
|
||||
"""
|
||||
search = Input(attributes=search_attributes)
|
||||
|
||||
@@ -191,10 +201,11 @@ def _combobox_shell(
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
return Div(
|
||||
attributes=container_attributes,
|
||||
children=[pills, search, options_panel, *(templates or [])],
|
||||
)
|
||||
children: list[SafeText] = []
|
||||
if leading is not None:
|
||||
children.append(leading)
|
||||
children += [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
@@ -397,6 +408,35 @@ def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _filter_match_select(match_modes: list[LabeledOption], active: str) -> SafeText:
|
||||
"""The include-set match-mode ``<select>`` (e.g. any/all/none).
|
||||
|
||||
Each option's value is a ``Modifier`` name (INCLUDES / INCLUDES_ALL /
|
||||
EXCLUDES) that governs how the include (✓) pills match; the exclude (✗) pills
|
||||
stay an orthogonal negative. ``readSearchSelect`` reads the chosen value into
|
||||
the container's ``data-match`` and ``filter_bar.js`` folds it into the
|
||||
criterion's ``modifier``. Distinct from the pinned (Any)/(None) pseudo-options
|
||||
(presence: NOT_NULL / IS_NULL), which clear the value pills.
|
||||
"""
|
||||
option_nodes: list[SafeText] = []
|
||||
for modifier_value, label in match_modes:
|
||||
attributes: list[HTMLAttribute] = [("value", modifier_value)]
|
||||
if modifier_value == active:
|
||||
attributes.append(("selected", ""))
|
||||
option_nodes.append(
|
||||
Component(tag_name="option", attributes=attributes, children=[label])
|
||||
)
|
||||
return Component(
|
||||
tag_name="select",
|
||||
attributes=[
|
||||
("data-search-select-match", ""),
|
||||
("aria-label", "Match mode"),
|
||||
("class", _FILTER_MATCH_SELECT_CLASS),
|
||||
],
|
||||
children=option_nodes,
|
||||
)
|
||||
|
||||
|
||||
def FilterSelect(
|
||||
*,
|
||||
field_name: str,
|
||||
@@ -405,6 +445,8 @@ def FilterSelect(
|
||||
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
modifier: str = "",
|
||||
modifier_options: list[LabeledOption] | None = None,
|
||||
match: str = "",
|
||||
match_modes: list[LabeledOption] | None = None,
|
||||
search_url: str = "",
|
||||
prefetch: int = 0,
|
||||
items_visible: int = 6,
|
||||
@@ -421,6 +463,14 @@ def FilterSelect(
|
||||
value pills. State is read from the DOM into the filter JSON by
|
||||
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``.
|
||||
|
||||
When ``match_modes`` is given (e.g.
|
||||
``[("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]``) a
|
||||
small ``<select>`` is rendered before the pills, letting the user choose how
|
||||
the include (✓) set matches — Stash's modifier axis. ``match`` is the active
|
||||
one (defaults to the first). It is orthogonal to ``modifier_options`` (which
|
||||
handle presence) and to the exclude (✗) channel. ``INCLUDES_ALL`` is only
|
||||
meaningful for many-to-many fields.
|
||||
|
||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||
labels even when the value rows come from ``search_url``. ``options``
|
||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||
@@ -429,6 +479,8 @@ def FilterSelect(
|
||||
included = [_normalize_option(option) for option in (included or [])]
|
||||
excluded = [_normalize_option(option) for option in (excluded or [])]
|
||||
modifier_options = modifier_options or []
|
||||
match_modes = match_modes or []
|
||||
active_match = match or (match_modes[0][0] if match_modes else "")
|
||||
|
||||
active_modifier_label = ""
|
||||
for modifier_value, label in modifier_options:
|
||||
@@ -512,9 +564,13 @@ def FilterSelect(
|
||||
]
|
||||
if modifier:
|
||||
container_attributes.append(("data-modifier", modifier))
|
||||
if match_modes:
|
||||
container_attributes.append(("data-match", active_match))
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
leading = _filter_match_select(match_modes, active_match) if match_modes else None
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
@@ -523,6 +579,7 @@ def FilterSelect(
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
leading=leading,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user