Unify UI for filter modifiers
This commit is contained in:
@@ -93,50 +93,51 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
|||||||
|
|
||||||
_FILTER_PREFETCH = 20
|
_FILTER_PREFETCH = 20
|
||||||
|
|
||||||
# Presence modifiers drive the pinned (Any)/(None) pseudo-options (they clear the
|
# Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
|
||||||
# value set); every other modifier is a match mode for the include set.
|
# mutually exclusive with value pills (selecting one clears the value set).
|
||||||
|
# Must match JS PRESENCE_MODIFIERS in search_select.js.
|
||||||
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
|
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
|
||||||
|
|
||||||
# Include-set match modes (Stash's any/all/none axis). Offered only for
|
# M2M-only modifiers surfaced as additional pseudo-options in the dropdown.
|
||||||
# many-to-many fields, where INCLUDES_ALL ("related to all of these") is
|
# "any" (INCLUDES) is the implicit default when neither a presence nor an
|
||||||
# meaningful — a single-valued field can never match all of several values.
|
# M2M modifier is set — no dedicated row needed. "none" (EXCLUDES) is
|
||||||
_MATCH_MODES: list[LabeledOption] = [
|
# redundant with individual exclude (✗) pills. Only INCLUDES_ALL and
|
||||||
("INCLUDES", "any"),
|
# INCLUDES_ONLY can't be expressed through pills alone, so they are the
|
||||||
("INCLUDES_ALL", "all"),
|
# only M2M modifiers with explicit UI.
|
||||||
("INCLUDES_ONLY", "only"),
|
_M2M_MODIFIERS: list[LabeledOption] = [
|
||||||
("EXCLUDES", "none"),
|
("INCLUDES_ALL", "(All)"),
|
||||||
|
("INCLUDES_ONLY", "(Only)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _modifier_options(nullable: bool) -> list[LabeledOption]:
|
def _modifier_options(
|
||||||
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
nullable: bool, m2m_modifiers: list[LabeledOption] | None = None
|
||||||
options = [("NOT_NULL", "(Any)")]
|
) -> list[LabeledOption]:
|
||||||
|
"""Pinned pseudo-options rendered at the top of the dropdown.
|
||||||
|
|
||||||
|
Always includes ``(Any)`` (NOT_NULL); adds ``(None)`` (IS_NULL) when
|
||||||
|
``nullable`` is True. When ``m2m_modifiers`` is given (M2M fields only),
|
||||||
|
appends those rows (e.g. ``(All)`` / ``(Only)``)."""
|
||||||
|
options: list[LabeledOption] = [("NOT_NULL", "(Any)")]
|
||||||
if nullable:
|
if nullable:
|
||||||
options.append(("IS_NULL", "(None)"))
|
options.append(("IS_NULL", "(None)"))
|
||||||
|
if m2m_modifiers:
|
||||||
|
options.extend(m2m_modifiers)
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def _split_modifier(
|
def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
||||||
modifier: str, match_modes: list[LabeledOption] | None
|
"""Return the modifier value to surface as the modifier pill.
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Split a stored modifier into ``(presence_modifier, match_mode)``.
|
|
||||||
|
|
||||||
A criterion stores a single ``modifier``, but the widget surfaces it on two
|
Presence modifiers (NOT_NULL / IS_NULL) are always surfaced. Non-presence
|
||||||
orthogonal controls: the pinned (Any)/(None) presence pseudo-options and the
|
modifiers (INCLUDES / INCLUDES_ALL / INCLUDES_ONLY) only need a pill on M2M
|
||||||
match-mode select. Presence modifiers (NOT_NULL/IS_NULL) route to the former;
|
fields — otherwise the modifier is just the implicit default.
|
||||||
the rest (INCLUDES/INCLUDES_ALL/EXCLUDES) to the latter. The match mode is
|
|
||||||
irrelevant when the field has no match-mode control, and falls back to the
|
|
||||||
first offered mode otherwise.
|
|
||||||
"""
|
"""
|
||||||
default_match = match_modes[0][0] if match_modes else ""
|
if modifier in _PRESENCE_MODIFIERS or not has_m2m:
|
||||||
if modifier in _PRESENCE_MODIFIERS or not match_modes:
|
return modifier
|
||||||
# When there's no match-mode select, the modifier stays whole — it IS
|
|
||||||
# the full criterion modifier (enum/choice fields). Only split when a
|
|
||||||
# match-mode axis exists to receive the non-presence part.
|
|
||||||
return modifier, default_match
|
|
||||||
if modifier:
|
if modifier:
|
||||||
return "", modifier
|
return modifier
|
||||||
return "", default_match
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _enum_filter(
|
def _enum_filter(
|
||||||
@@ -144,7 +145,7 @@ def _enum_filter(
|
|||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
||||||
|
|
||||||
Enum fields are single-valued, so no match-mode control (any/all/none is
|
Enum fields are single-valued, so no M2M modifiers (all/only are
|
||||||
meaningless); only the presence modifier is surfaced.
|
meaningless); only the presence modifier is surfaced.
|
||||||
"""
|
"""
|
||||||
options_str = [(str(value), label) for value, label in options]
|
options_str = [(str(value), label) for value, label in options]
|
||||||
@@ -154,13 +155,13 @@ def _enum_filter(
|
|||||||
excluded = [
|
excluded = [
|
||||||
(value, _find_label(options_str, value)) for value, _label in choice.excluded
|
(value, _find_label(options_str, value)) for value, _label in choice.excluded
|
||||||
]
|
]
|
||||||
presence, _match = _split_modifier(choice.modifier, None)
|
modifier = _split_modifier(choice.modifier)
|
||||||
return FilterSelect(
|
return FilterSelect(
|
||||||
field_name=field_name,
|
field_name=field_name,
|
||||||
options=options_str,
|
options=options_str,
|
||||||
included=included,
|
included=included,
|
||||||
excluded=excluded,
|
excluded=excluded,
|
||||||
modifier=presence,
|
modifier=modifier,
|
||||||
modifier_options=_modifier_options(nullable),
|
modifier_options=_modifier_options(nullable),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,23 +172,22 @@ def _model_filter(
|
|||||||
*,
|
*,
|
||||||
search_url,
|
search_url,
|
||||||
nullable,
|
nullable,
|
||||||
match_modes: list[LabeledOption] | None = None,
|
m2m_modifiers: list[LabeledOption] | None = None,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""A FilterSelect backed by a search endpoint.
|
"""A FilterSelect backed by a search endpoint.
|
||||||
|
|
||||||
Labels are embedded in the filter JSON (Stash-style), so pills render
|
Labels are embedded in the filter JSON (Stash-style), so pills render
|
||||||
directly from ``choice`` with no DB round-trip. Pass ``match_modes`` for
|
directly from ``choice`` with no DB round-trip. Pass ``m2m_modifiers`` for
|
||||||
many-to-many fields to surface the any/all/none match-mode select.
|
many-to-many fields to surface ``(All)`` / ``(Only)`` pseudo-options in the
|
||||||
|
dropdown alongside the presence options.
|
||||||
"""
|
"""
|
||||||
presence, match = _split_modifier(choice.modifier, match_modes)
|
modifier = _split_modifier(choice.modifier, has_m2m=bool(m2m_modifiers))
|
||||||
return FilterSelect(
|
return FilterSelect(
|
||||||
field_name=field_name,
|
field_name=field_name,
|
||||||
included=[(value, label or value) for value, label in choice.selected],
|
included=[(value, label or value) for value, label in choice.selected],
|
||||||
excluded=[(value, label or value) for value, label in choice.excluded],
|
excluded=[(value, label or value) for value, label in choice.excluded],
|
||||||
modifier=presence,
|
modifier=modifier,
|
||||||
modifier_options=_modifier_options(nullable),
|
modifier_options=_modifier_options(nullable, m2m_modifiers),
|
||||||
match=match,
|
|
||||||
match_modes=match_modes or [],
|
|
||||||
search_url=search_url,
|
search_url=search_url,
|
||||||
prefetch=_FILTER_PREFETCH,
|
prefetch=_FILTER_PREFETCH,
|
||||||
)
|
)
|
||||||
@@ -869,9 +869,10 @@ def PurchaseFilterBar(
|
|||||||
game_choice,
|
game_choice,
|
||||||
search_url="/api/games/search",
|
search_url="/api/games/search",
|
||||||
nullable=False,
|
nullable=False,
|
||||||
# games is many-to-many on Purchase: "all" (INCLUDES_ALL)
|
# games is many-to-many on Purchase: (All) means
|
||||||
# means a purchase linked to every selected game.
|
# INCLUDES_ALL ("purchase linked to every selected
|
||||||
match_modes=_MATCH_MODES,
|
# game"); (Only) means INCLUDES_ONLY.
|
||||||
|
m2m_modifiers=_M2M_MODIFIERS,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
|
|||||||
@@ -101,14 +101,6 @@ _FILTER_ACTION_BUTTON_CLASS = (
|
|||||||
_FILTER_MODIFIER_ROW_CLASS = (
|
_FILTER_MODIFIER_ROW_CLASS = (
|
||||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
"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:
|
def _normalize_option(option) -> SearchSelectOption:
|
||||||
@@ -167,7 +159,6 @@ def _combobox_shell(
|
|||||||
always_visible: bool,
|
always_visible: bool,
|
||||||
items_visible: int,
|
items_visible: int,
|
||||||
templates: list[SafeText] | None = None,
|
templates: list[SafeText] | None = None,
|
||||||
leading: SafeText | None = None,
|
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||||
|
|
||||||
@@ -178,9 +169,8 @@ def _combobox_shell(
|
|||||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||||
dynamically-added rows/pills). An optional ``leading`` element is placed
|
dynamically-added rows/pills). The shell knows nothing about how individual
|
||||||
before the pills (e.g. the filter match-mode select). The shell knows nothing
|
rows or pills look.
|
||||||
about how individual rows or pills look.
|
|
||||||
"""
|
"""
|
||||||
search = Input(attributes=search_attributes)
|
search = Input(attributes=search_attributes)
|
||||||
|
|
||||||
@@ -201,10 +191,7 @@ def _combobox_shell(
|
|||||||
children=[*options_children, no_results],
|
children=[*options_children, no_results],
|
||||||
)
|
)
|
||||||
|
|
||||||
children: list[SafeText] = []
|
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||||
if leading is not None:
|
|
||||||
children.append(leading)
|
|
||||||
children += [pills, search, options_panel, *(templates or [])]
|
|
||||||
return Div(attributes=container_attributes, children=children)
|
return Div(attributes=container_attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
@@ -408,35 +395,6 @@ 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(
|
def FilterSelect(
|
||||||
*,
|
*,
|
||||||
field_name: str,
|
field_name: str,
|
||||||
@@ -445,8 +403,6 @@ def FilterSelect(
|
|||||||
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||||
modifier: str = "",
|
modifier: str = "",
|
||||||
modifier_options: list[LabeledOption] | None = None,
|
modifier_options: list[LabeledOption] | None = None,
|
||||||
match: str = "",
|
|
||||||
match_modes: list[LabeledOption] | None = None,
|
|
||||||
search_url: str = "",
|
search_url: str = "",
|
||||||
prefetch: int = 0,
|
prefetch: int = 0,
|
||||||
items_visible: int = 6,
|
items_visible: int = 6,
|
||||||
@@ -459,17 +415,12 @@ def FilterSelect(
|
|||||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||||
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
|
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
|
||||||
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
|
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
|
||||||
rendered above the value rows. A selected modifier is mutually exclusive with
|
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
|
||||||
value pills. State is read from the DOM into the filter JSON by
|
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
|
||||||
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``.
|
INCLUDES_ONLY) coexist with value pills — they govern how the include set
|
||||||
|
matches and are only surfaced for many-to-many fields. State is read from
|
||||||
When ``match_modes`` is given (e.g.
|
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
|
||||||
``[("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]``) a
|
is submitted by ``name``.
|
||||||
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
|
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||||
labels even when the value rows come from ``search_url``. ``options``
|
labels even when the value rows come from ``search_url``. ``options``
|
||||||
@@ -479,8 +430,6 @@ def FilterSelect(
|
|||||||
included = [_normalize_option(option) for option in (included or [])]
|
included = [_normalize_option(option) for option in (included or [])]
|
||||||
excluded = [_normalize_option(option) for option in (excluded or [])]
|
excluded = [_normalize_option(option) for option in (excluded or [])]
|
||||||
modifier_options = modifier_options 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 = ""
|
active_modifier_label = ""
|
||||||
for modifier_value, label in modifier_options:
|
for modifier_value, label in modifier_options:
|
||||||
@@ -488,15 +437,18 @@ def FilterSelect(
|
|||||||
active_modifier_label = label
|
active_modifier_label = label
|
||||||
break
|
break
|
||||||
|
|
||||||
# ── Pills: a lone modifier pill, or include/exclude value pills ──
|
# ── Pills: modifier pill (if active), then include/exclude value pills ──
|
||||||
|
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
|
||||||
|
# pills — but the stored state guarantees they never coexist, so we render
|
||||||
|
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||||
|
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||||
pills_children: list[SafeText] = []
|
pills_children: list[SafeText] = []
|
||||||
if active_modifier_label:
|
if active_modifier_label:
|
||||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||||
else:
|
for option in included:
|
||||||
for option in included:
|
pills_children.append(_filter_value_pill(option, "include"))
|
||||||
pills_children.append(_filter_value_pill(option, "include"))
|
for option in excluded:
|
||||||
for option in excluded:
|
pills_children.append(_filter_value_pill(option, "exclude"))
|
||||||
pills_children.append(_filter_value_pill(option, "exclude"))
|
|
||||||
|
|
||||||
pills = Div(
|
pills = Div(
|
||||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||||
@@ -564,13 +516,9 @@ def FilterSelect(
|
|||||||
]
|
]
|
||||||
if modifier:
|
if modifier:
|
||||||
container_attributes.append(("data-modifier", modifier))
|
container_attributes.append(("data-modifier", modifier))
|
||||||
if match_modes:
|
|
||||||
container_attributes.append(("data-match", active_match))
|
|
||||||
if id:
|
if id:
|
||||||
container_attributes.append(("id", id))
|
container_attributes.append(("id", id))
|
||||||
|
|
||||||
leading = _filter_match_select(match_modes, active_match) if match_modes else None
|
|
||||||
|
|
||||||
return _combobox_shell(
|
return _combobox_shell(
|
||||||
container_attributes=container_attributes,
|
container_attributes=container_attributes,
|
||||||
pills=pills,
|
pills=pills,
|
||||||
@@ -579,7 +527,6 @@ def FilterSelect(
|
|||||||
always_visible=False,
|
always_visible=False,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
leading=leading,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -293,85 +293,27 @@
|
|||||||
--leading-5: 20px;
|
--leading-5: 20px;
|
||||||
--radius-base: 12px;
|
--radius-base: 12px;
|
||||||
--color-body: var(--color-gray-600);
|
--color-body: var(--color-gray-600);
|
||||||
--color-body-subtle: var(--color-gray-500);
|
|
||||||
--color-heading: var(--color-gray-900);
|
--color-heading: var(--color-gray-900);
|
||||||
--color-fg-brand-subtle: var(--color-blue-200);
|
|
||||||
--color-fg-brand: var(--color-blue-700);
|
--color-fg-brand: var(--color-blue-700);
|
||||||
--color-fg-brand-strong: var(--color-blue-900);
|
|
||||||
--color-fg-success: var(--color-emerald-700);
|
|
||||||
--color-fg-success-strong: var(--color-emerald-900);
|
|
||||||
--color-fg-danger: var(--color-rose-700);
|
|
||||||
--color-fg-danger-strong: var(--color-rose-900);
|
|
||||||
--color-fg-warning-subtle: var(--color-orange-600);
|
|
||||||
--color-fg-warning: var(--color-orange-900);
|
|
||||||
--color-fg-yellow: var(--color-yellow-400);
|
|
||||||
--color-fg-disabled: var(--color-gray-400);
|
--color-fg-disabled: var(--color-gray-400);
|
||||||
--color-fg-purple: var(--color-purple-600);
|
|
||||||
--color-fg-cyan: var(--color-cyan-600);
|
|
||||||
--color-fg-indigo: var(--color-indigo-600);
|
|
||||||
--color-fg-pink: var(--color-pink-600);
|
|
||||||
--color-fg-lime: var(--color-lime-600);
|
|
||||||
--color-neutral-primary-soft: var(--color-white);
|
--color-neutral-primary-soft: var(--color-white);
|
||||||
--color-neutral-primary: var(--color-white);
|
--color-neutral-primary: var(--color-white);
|
||||||
--color-neutral-primary-medium: var(--color-white);
|
--color-neutral-primary-medium: var(--color-white);
|
||||||
--color-neutral-primary-strong: var(--color-white);
|
|
||||||
--color-neutral-secondary-soft: var(--color-gray-50);
|
--color-neutral-secondary-soft: var(--color-gray-50);
|
||||||
--color-neutral-secondary: var(--color-gray-50);
|
--color-neutral-secondary: var(--color-gray-50);
|
||||||
--color-neutral-secondary-medium: var(--color-gray-50);
|
--color-neutral-secondary-medium: var(--color-gray-50);
|
||||||
--color-neutral-secondary-strong: var(--color-gray-50);
|
--color-neutral-secondary-strong: var(--color-gray-50);
|
||||||
--color-neutral-secondary-strongest: var(--color-gray-50);
|
|
||||||
--color-neutral-tertiary-soft: var(--color-gray-100);
|
|
||||||
--color-neutral-tertiary: var(--color-gray-100);
|
--color-neutral-tertiary: var(--color-gray-100);
|
||||||
--color-neutral-tertiary-medium: var(--color-gray-100);
|
--color-neutral-tertiary-medium: var(--color-gray-100);
|
||||||
--color-neutral-quaternary: var(--color-gray-200);
|
--color-neutral-quaternary: var(--color-gray-200);
|
||||||
--color-neutral-quaternary-medium: var(--color-gray-200);
|
|
||||||
--color-gray: var(--color-gray-300);
|
|
||||||
--color-brand-softer: var(--color-blue-50);
|
|
||||||
--color-brand-soft: var(--color-blue-100);
|
--color-brand-soft: var(--color-blue-100);
|
||||||
--color-brand: var(--color-blue-700);
|
--color-brand: var(--color-blue-700);
|
||||||
--color-brand-medium: var(--color-blue-200);
|
--color-brand-medium: var(--color-blue-200);
|
||||||
--color-brand-strong: var(--color-blue-800);
|
--color-brand-strong: var(--color-blue-800);
|
||||||
--color-success-soft: var(--color-emerald-50);
|
|
||||||
--color-success: var(--color-emerald-700);
|
|
||||||
--color-success-medium: var(--color-emerald-100);
|
|
||||||
--color-success-strong: var(--color-emerald-800);
|
|
||||||
--color-danger-soft: var(--color-rose-50);
|
|
||||||
--color-danger: var(--color-rose-700);
|
|
||||||
--color-danger-medium: var(--color-rose-100);
|
|
||||||
--color-danger-strong: var(--color-rose-800);
|
|
||||||
--color-warning-soft: var(--color-orange-50);
|
|
||||||
--color-warning: var(--color-orange-500);
|
|
||||||
--color-warning-medium: var(--color-orange-100);
|
|
||||||
--color-warning-strong: var(--color-orange-700);
|
|
||||||
--color-dark-soft: var(--color-gray-800);
|
|
||||||
--color-dark: var(--color-gray-800);
|
--color-dark: var(--color-gray-800);
|
||||||
--color-dark-strong: var(--color-gray-900);
|
|
||||||
--color-disabled: var(--color-gray-100);
|
|
||||||
--color-purple: var(--color-purple-500);
|
|
||||||
--color-sky: var(--color-sky-500);
|
|
||||||
--color-teal: var(--color-teal-600);
|
|
||||||
--color-pink: var(--color-pink-600);
|
|
||||||
--color-cyan: var(--color-cyan-500);
|
|
||||||
--color-fuchsia: var(--color-fuchsia-600);
|
|
||||||
--color-indigo: var(--color-indigo-600);
|
|
||||||
--color-orange: var(--color-orange-400);
|
|
||||||
--color-buffer: var(--color-white);
|
|
||||||
--color-buffer-medium: var(--color-white);
|
|
||||||
--color-buffer-strong: var(--color-white);
|
|
||||||
--color-muted: var(--color-gray-50);
|
|
||||||
--color-light-subtle: var(--color-gray-100);
|
|
||||||
--color-light: var(--color-gray-100);
|
--color-light: var(--color-gray-100);
|
||||||
--color-light-medium: var(--color-gray-100);
|
|
||||||
--color-default-subtle: var(--color-gray-200);
|
|
||||||
--color-default: var(--color-gray-200);
|
--color-default: var(--color-gray-200);
|
||||||
--color-default-medium: var(--color-gray-200);
|
--color-default-medium: var(--color-gray-200);
|
||||||
--color-default-strong: var(--color-gray-200);
|
|
||||||
--color-success-subtle: var(--color-emerald-200);
|
|
||||||
--color-danger-subtle: var(--color-rose-200);
|
|
||||||
--color-warning-subtle: var(--color-orange-200);
|
|
||||||
--color-brand-subtle: var(--color-blue-200);
|
|
||||||
--color-brand-light: var(--color-blue-600);
|
|
||||||
--color-dark-subtle: var(--color-gray-800);
|
|
||||||
--color-dark-backdrop: var(--color-gray-950);
|
--color-dark-backdrop: var(--color-gray-950);
|
||||||
--color-accent: #7c3aed;
|
--color-accent: #7c3aed;
|
||||||
}
|
}
|
||||||
@@ -881,18 +823,12 @@
|
|||||||
.start-0 {
|
.start-0 {
|
||||||
inset-inline-start: calc(var(--spacing) * 0);
|
inset-inline-start: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.end-1 {
|
|
||||||
inset-inline-end: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.end-1\.5 {
|
.end-1\.5 {
|
||||||
inset-inline-end: calc(var(--spacing) * 1.5);
|
inset-inline-end: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.top-1 {
|
|
||||||
top: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: calc(1 / 2 * 100%);
|
top: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
@@ -914,9 +850,6 @@
|
|||||||
.bottom-0 {
|
.bottom-0 {
|
||||||
bottom: calc(var(--spacing) * 0);
|
bottom: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.bottom-1 {
|
|
||||||
bottom: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.bottom-1\.5 {
|
.bottom-1\.5 {
|
||||||
bottom: calc(var(--spacing) * 1.5);
|
bottom: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
@@ -1626,15 +1559,9 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.w-1 {
|
|
||||||
width: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.w-1\/2 {
|
.w-1\/2 {
|
||||||
width: calc(1 / 2 * 100%);
|
width: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
.w-2 {
|
|
||||||
width: calc(var(--spacing) * 2);
|
|
||||||
}
|
|
||||||
.w-2\.5 {
|
.w-2\.5 {
|
||||||
width: calc(var(--spacing) * 2.5);
|
width: calc(var(--spacing) * 2.5);
|
||||||
}
|
}
|
||||||
@@ -1752,9 +1679,6 @@
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.border-collapse {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.-translate-x-full {
|
.-translate-x-full {
|
||||||
--tw-translate-x: -100%;
|
--tw-translate-x: -100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1771,10 +1695,6 @@
|
|||||||
--tw-translate-x: 100%;
|
--tw-translate-x: 100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
}
|
}
|
||||||
.-translate-y-1 {
|
|
||||||
--tw-translate-y: calc(var(--spacing) * -1);
|
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
|
||||||
}
|
|
||||||
.-translate-y-1\/2 {
|
.-translate-y-1\/2 {
|
||||||
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -2160,18 +2080,12 @@
|
|||||||
.bg-amber-50 {
|
.bg-amber-50 {
|
||||||
background-color: var(--color-amber-50);
|
background-color: var(--color-amber-50);
|
||||||
}
|
}
|
||||||
.bg-amber-500 {
|
|
||||||
background-color: var(--color-amber-500);
|
|
||||||
}
|
|
||||||
.bg-amber-500\/15 {
|
.bg-amber-500\/15 {
|
||||||
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-black {
|
|
||||||
background-color: var(--color-black);
|
|
||||||
}
|
|
||||||
.bg-black\/70 {
|
.bg-black\/70 {
|
||||||
background-color: color-mix(in srgb, #000 70%, transparent);
|
background-color: color-mix(in srgb, #000 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2196,9 +2110,6 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-dark-backdrop {
|
|
||||||
background-color: var(--color-dark-backdrop);
|
|
||||||
}
|
|
||||||
.bg-dark-backdrop\/70 {
|
.bg-dark-backdrop\/70 {
|
||||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2217,18 +2128,12 @@
|
|||||||
.bg-gray-500 {
|
.bg-gray-500 {
|
||||||
background-color: var(--color-gray-500);
|
background-color: var(--color-gray-500);
|
||||||
}
|
}
|
||||||
.bg-gray-800 {
|
|
||||||
background-color: var(--color-gray-800);
|
|
||||||
}
|
|
||||||
.bg-gray-800\/20 {
|
.bg-gray-800\/20 {
|
||||||
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
|
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
|
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-gray-900 {
|
|
||||||
background-color: var(--color-gray-900);
|
|
||||||
}
|
|
||||||
.bg-gray-900\/50 {
|
.bg-gray-900\/50 {
|
||||||
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
|
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2358,18 +2263,6 @@
|
|||||||
fill: white !important;
|
fill: white !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.apexcharts-gridline {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
.dark & {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.apexcharts-xcrosshairs {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
.dark & {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.apexcharts-ycrosshairs {
|
.apexcharts-ycrosshairs {
|
||||||
stroke: var(--color-default) !important;
|
stroke: var(--color-default) !important;
|
||||||
.dark & {
|
.dark & {
|
||||||
@@ -2428,9 +2321,6 @@
|
|||||||
.px-6 {
|
.px-6 {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
.py-0 {
|
|
||||||
padding-block: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.py-0\.5 {
|
.py-0\.5 {
|
||||||
padding-block: calc(var(--spacing) * 0.5);
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -2657,9 +2547,6 @@
|
|||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.text-wrap {
|
|
||||||
text-wrap: wrap;
|
|
||||||
}
|
|
||||||
.whitespace-nowrap {
|
.whitespace-nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -2795,9 +2682,6 @@
|
|||||||
.line-through {
|
.line-through {
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
}
|
}
|
||||||
.no-underline {
|
|
||||||
text-decoration-line: none;
|
|
||||||
}
|
|
||||||
.no-underline\! {
|
.no-underline\! {
|
||||||
text-decoration-line: none !important;
|
text-decoration-line: none !important;
|
||||||
}
|
}
|
||||||
@@ -2864,10 +2748,6 @@
|
|||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
}
|
}
|
||||||
.backdrop-filter {
|
|
||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
|
||||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
|
||||||
}
|
|
||||||
.transition {
|
.transition {
|
||||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
|||||||
@@ -68,22 +68,21 @@
|
|||||||
var included = parseJSONAttr(widget, "data-included");
|
var included = parseJSONAttr(widget, "data-included");
|
||||||
var excluded = parseJSONAttr(widget, "data-excluded");
|
var excluded = parseJSONAttr(widget, "data-excluded");
|
||||||
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
||||||
// pinned (Any)/(None) pseudo-options clears the value set, while the
|
// pinned (Any)/(None) pseudo-options clears the value set and has no
|
||||||
// match mode (INCLUDES/INCLUDES_ALL/EXCLUDES) governs how the include set
|
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
|
||||||
// matches. Fields without a data-match attribute have no match-mode select
|
// how the include set matches. When neither is set the implicit default
|
||||||
// — the full modifier lives in data-modifier (e.g. enum/choice fields).
|
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
|
||||||
var presence = widget.getAttribute("data-modifier");
|
var modifier = widget.getAttribute("data-modifier");
|
||||||
var matchVal = widget.getAttribute("data-match");
|
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
||||||
var match = matchVal || presence || "INCLUDES";
|
if (IS_PRESENCE) {
|
||||||
if (presence === "NOT_NULL" || presence === "IS_NULL") {
|
filter[field] = { modifier: modifier };
|
||||||
filter[field] = { modifier: presence };
|
|
||||||
} else if (included.length > 0 || excluded.length > 0) {
|
} else if (included.length > 0 || excluded.length > 0) {
|
||||||
// All filter pills carry {id, label}; store them as-is so the filter
|
// All filter pills carry {id, label}; store them as-is so the filter
|
||||||
// URL and saved presets are self-describing (Stash-style).
|
// URL and saved presets are self-describing (Stash-style).
|
||||||
filter[field] = {
|
filter[field] = {
|
||||||
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
|
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||||
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
|
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||||
modifier: match,
|
modifier: modifier || "INCLUDES",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,12 @@
|
|||||||
|
|
||||||
var DEBOUNCE_MS = 100;
|
var DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
|
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||||
|
// These modifiers are mutually exclusive with value pills — selecting
|
||||||
|
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||||
|
// INCLUDES_ONLY) coexist with value pills.
|
||||||
|
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||||
|
|
||||||
function initAll() {
|
function initAll() {
|
||||||
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
||||||
if (element._searchSelectInit) return;
|
if (element._searchSelectInit) return;
|
||||||
@@ -356,9 +362,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||||
// clears an active modifier — the two are mutually exclusive.
|
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||||
|
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||||
|
// persist alongside value pills.
|
||||||
function addFilterPill(option, kind) {
|
function addFilterPill(option, kind) {
|
||||||
clearModifier();
|
var modPill = pills.querySelector("[data-search-select-modifier]");
|
||||||
|
if (modPill) {
|
||||||
|
var modVal = modPill.getAttribute("data-search-select-modifier");
|
||||||
|
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) {
|
||||||
|
clearModifier();
|
||||||
|
}
|
||||||
|
}
|
||||||
var existing = pills.querySelector(
|
var existing = pills.querySelector(
|
||||||
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
|
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
|
||||||
);
|
);
|
||||||
@@ -377,24 +391,36 @@
|
|||||||
return pill;
|
return pill;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
|
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||||
|
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||||
|
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||||
function setModifier(modifierValue, label) {
|
function setModifier(modifierValue, label) {
|
||||||
pills.innerHTML = "";
|
// Remove any existing modifier pill to avoid duplicates.
|
||||||
|
clearModifierPill();
|
||||||
|
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) {
|
||||||
|
pills.innerHTML = "";
|
||||||
|
}
|
||||||
var pill = cloneTemplate("pill-modifier");
|
var pill = cloneTemplate("pill-modifier");
|
||||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||||
setLabel(pill, label);
|
setLabel(pill, label);
|
||||||
pills.appendChild(pill);
|
pills.insertBefore(pill, pills.firstChild);
|
||||||
container.setAttribute("data-modifier", modifierValue);
|
container.setAttribute("data-modifier", modifierValue);
|
||||||
hidePanel();
|
hidePanel();
|
||||||
emitChange(null);
|
emitChange(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearModifier() {
|
// Remove the modifier pill and its container attribute. Safe to call when
|
||||||
|
// there is no modifier pill (no-op). Does not touch value pills.
|
||||||
|
function clearModifierPill() {
|
||||||
var modifierPill = pills.querySelector("[data-search-select-modifier]");
|
var modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||||
if (modifierPill) modifierPill.remove();
|
if (modifierPill) modifierPill.remove();
|
||||||
container.removeAttribute("data-modifier");
|
container.removeAttribute("data-modifier");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearModifier() {
|
||||||
|
clearModifierPill();
|
||||||
|
}
|
||||||
|
|
||||||
function optionFromRow(row) {
|
function optionFromRow(row) {
|
||||||
if (row._searchSelectOption) return row._searchSelectOption;
|
if (row._searchSelectOption) return row._searchSelectOption;
|
||||||
var data = {};
|
var data = {};
|
||||||
@@ -458,12 +484,12 @@
|
|||||||
var pill = removeButton.closest("[data-pill]");
|
var pill = removeButton.closest("[data-pill]");
|
||||||
if (!pill) return;
|
if (!pill) return;
|
||||||
if (isFilter) {
|
if (isFilter) {
|
||||||
// Filter pills have no hidden input; a modifier pill also clears the
|
// Filter pills have no hidden input.
|
||||||
// container flag.
|
|
||||||
if (pill.hasAttribute("data-search-select-modifier")) {
|
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||||
container.removeAttribute("data-modifier");
|
clearModifierPill();
|
||||||
|
} else {
|
||||||
|
pill.remove();
|
||||||
}
|
}
|
||||||
pill.remove();
|
|
||||||
emitChange(null);
|
emitChange(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -538,8 +564,8 @@
|
|||||||
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
||||||
var pillModifier = pill.getAttribute("data-search-select-modifier");
|
var pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||||
if (pillModifier) {
|
if (pillModifier) {
|
||||||
modifier = pillModifier;
|
modifier = pillModifier; // last modifier pill wins
|
||||||
return;
|
return; // skip value extraction for this pill
|
||||||
}
|
}
|
||||||
var value = pill.getAttribute("data-value");
|
var value = pill.getAttribute("data-value");
|
||||||
var label = pill.getAttribute("data-label") || "";
|
var label = pill.getAttribute("data-label") || "";
|
||||||
@@ -554,11 +580,6 @@
|
|||||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||||
else container.removeAttribute("data-modifier");
|
else container.removeAttribute("data-modifier");
|
||||||
// The match-mode <select> (any/all/none) governs how the include set
|
|
||||||
// matches; its value is the criterion modifier. A native control, so its
|
|
||||||
// value is read directly — no pill bookkeeping.
|
|
||||||
var matchSelect = container.querySelector("[data-search-select-match]");
|
|
||||||
if (matchSelect) container.setAttribute("data-match", matchSelect.value);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var values = pills
|
var values = pills
|
||||||
|
|||||||
+15
-12
@@ -92,27 +92,30 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
||||||
self._assert_range_slider(html)
|
self._assert_range_slider(html)
|
||||||
|
|
||||||
def test_purchase_filter_bar_games_has_match_modes(self):
|
def test_purchase_filter_bar_games_has_m2m_modifiers(self):
|
||||||
"""The many-to-many games field surfaces the any/all/only/none match
|
"""The many-to-many games field surfaces (All)/(Only) pseudo-options
|
||||||
select; single-valued fields (platform) do not."""
|
in the dropdown alongside the presence (Any)/(None) rows. Single-valued
|
||||||
|
fields (platform) do not get M2M modifiers."""
|
||||||
html = str(
|
html = str(
|
||||||
PurchaseFilterBar(
|
PurchaseFilterBar(
|
||||||
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn("data-search-select-match", html)
|
# (All) and (Only) appear as modifier rows in the dropdown.
|
||||||
self.assertIn('value="INCLUDES_ALL"', html)
|
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
|
||||||
self.assertIn('value="INCLUDES_ONLY"', html)
|
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
|
||||||
# Platform is single-valued: no match select before its widget.
|
# No legacy match-mode <select>.
|
||||||
|
self.assertNotIn("data-search-select-match", html)
|
||||||
|
# Platform is single-valued: no M2M modifier options in its section.
|
||||||
games_start = html.find('data-name="games"')
|
games_start = html.find('data-name="games"')
|
||||||
platform_start = html.find('data-name="platform"')
|
platform_start = html.find('data-name="platform"')
|
||||||
platform_section = html[platform_start:]
|
platform_section = html[platform_start:]
|
||||||
self.assertNotIn("data-search-select-match", platform_section)
|
self.assertNotIn("INCLUDES_ALL", platform_section)
|
||||||
self.assertGreater(games_start, 0)
|
self.assertGreater(games_start, 0)
|
||||||
|
|
||||||
def test_purchase_filter_bar_roundtrips_includes_all(self):
|
def test_purchase_filter_bar_roundtrips_includes_all(self):
|
||||||
"""A stored INCLUDES_ALL modifier pre-selects the match <option> and the
|
"""A stored INCLUDES_ALL modifier renders as the modifier pill and the
|
||||||
included game still renders as a pill."""
|
included game still renders as a value pill."""
|
||||||
filter_json = json.dumps(
|
filter_json = json.dumps(
|
||||||
{
|
{
|
||||||
"games": {
|
"games": {
|
||||||
@@ -126,8 +129,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn('data-match="INCLUDES_ALL"', html)
|
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
||||||
self.assertIn('value="INCLUDES_ALL" selected=""', html)
|
self.assertIn("(All)", html) # modifier pill label
|
||||||
self.assertIn("Hollow Knight", html)
|
self.assertIn("Hollow Knight", html)
|
||||||
self.assertIn('data-search-select-type="include"', html)
|
self.assertIn('data-search-select-type="include"', html)
|
||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
|
|||||||
+59
-30
@@ -179,7 +179,9 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||||
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
|
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
|
||||||
|
|
||||||
def test_active_modifier_replaces_value_pills(self):
|
def test_modifier_pill_coexists_with_value_pills(self):
|
||||||
|
"""Modifier and value pills both render server-side; the JS handles
|
||||||
|
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
|
||||||
html = FilterSelect(
|
html = FilterSelect(
|
||||||
field_name="platform",
|
field_name="platform",
|
||||||
options=[("1", "Steam")],
|
options=[("1", "Steam")],
|
||||||
@@ -187,13 +189,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
modifier="IS_NULL",
|
modifier="IS_NULL",
|
||||||
modifier_options=self.MODIFIERS,
|
modifier_options=self.MODIFIERS,
|
||||||
)
|
)
|
||||||
# The lone modifier pill is shown; include/exclude pills are suppressed.
|
# Both the modifier pill and the value pill render.
|
||||||
# (Scope the check to the live pills region — the cloneable pill <template>s
|
|
||||||
# legitimately contain data-search-select-type.)
|
|
||||||
pills_region = html.split("data-search-select-template")[0]
|
|
||||||
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
||||||
self.assertIn("(None)", html)
|
self.assertIn("(None)", html)
|
||||||
self.assertNotIn('data-search-select-type="include"', pills_region)
|
self.assertIn(
|
||||||
|
'data-search-select-type="include"', html
|
||||||
|
) # value pill present
|
||||||
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
||||||
|
|
||||||
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
||||||
@@ -223,35 +224,63 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn(">Obscure Game</span>", html)
|
self.assertIn(">Obscure Game</span>", html)
|
||||||
self.assertIn('data-value="4172"', html)
|
self.assertIn('data-value="4172"', html)
|
||||||
|
|
||||||
MATCH_MODES = [
|
M2M_MODIFIERS = [
|
||||||
("INCLUDES", "any"),
|
("INCLUDES_ALL", "(All)"),
|
||||||
("INCLUDES_ALL", "all"),
|
("INCLUDES_ONLY", "(Only)"),
|
||||||
("INCLUDES_ONLY", "only"),
|
|
||||||
("EXCLUDES", "none"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_match_modes_render_native_select(self):
|
def test_m2m_modifiers_render_as_option_rows(self):
|
||||||
html = FilterSelect(field_name="games", match_modes=self.MATCH_MODES)
|
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
|
||||||
# A native <select> carries the include-set match mode; options are labels.
|
dropdown, not as a separate <select>."""
|
||||||
self.assertIn("data-search-select-match", html)
|
|
||||||
self.assertIn('value="INCLUDES_ALL"', html)
|
|
||||||
self.assertIn(">all</option>", html)
|
|
||||||
self.assertIn('value="INCLUDES_ONLY"', html)
|
|
||||||
self.assertIn(">only</option>", html)
|
|
||||||
# The container exposes the active mode (defaults to the first) for the JS.
|
|
||||||
self.assertIn('data-match="INCLUDES"', html)
|
|
||||||
|
|
||||||
def test_active_match_marks_selected_option(self):
|
|
||||||
html = FilterSelect(
|
html = FilterSelect(
|
||||||
field_name="games", match="INCLUDES_ALL", match_modes=self.MATCH_MODES
|
field_name="games",
|
||||||
|
modifier_options=[
|
||||||
|
("NOT_NULL", "(Any)"),
|
||||||
|
("IS_NULL", "(None)"),
|
||||||
|
("INCLUDES_ALL", "(All)"),
|
||||||
|
("INCLUDES_ONLY", "(Only)"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.assertIn('data-match="INCLUDES_ALL"', html)
|
self.assertIn(
|
||||||
self.assertIn('value="INCLUDES_ALL" selected=""', html)
|
'data-search-select-modifier-option="INCLUDES_ALL"', html
|
||||||
|
)
|
||||||
def test_no_match_modes_omits_select(self):
|
self.assertIn(
|
||||||
html = FilterSelect(field_name="status", options=[("f", "Finished")])
|
'data-search-select-modifier-option="INCLUDES_ONLY"', html
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'data-search-select-modifier-option="NOT_NULL"', html
|
||||||
|
)
|
||||||
|
# No legacy match-mode <select>.
|
||||||
self.assertNotIn("data-search-select-match", html)
|
self.assertNotIn("data-search-select-match", html)
|
||||||
self.assertNotIn("data-match=", html)
|
|
||||||
|
def test_active_modifier_renders_pill(self):
|
||||||
|
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
|
||||||
|
(All) label alongside any value pills."""
|
||||||
|
html = FilterSelect(
|
||||||
|
field_name="games",
|
||||||
|
modifier="INCLUDES_ALL",
|
||||||
|
modifier_options=[
|
||||||
|
("NOT_NULL", "(Any)"),
|
||||||
|
("IS_NULL", "(None)"),
|
||||||
|
("INCLUDES_ALL", "(All)"),
|
||||||
|
("INCLUDES_ONLY", "(Only)"),
|
||||||
|
],
|
||||||
|
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||||
|
)
|
||||||
|
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
||||||
|
self.assertIn("(All)", html)
|
||||||
|
self.assertIn("Hollow Knight", html)
|
||||||
|
self.assertIn('data-search-select-type="include"', html)
|
||||||
|
|
||||||
|
def test_presence_only_modifiers_no_m2m_rows(self):
|
||||||
|
"""When modifier_options only has presence entries, no M2M rows appear."""
|
||||||
|
html = FilterSelect(
|
||||||
|
field_name="status",
|
||||||
|
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||||
|
options=[("f", "Finished")],
|
||||||
|
)
|
||||||
|
self.assertNotIn("INCLUDES_ALL", html)
|
||||||
|
self.assertNotIn("INCLUDES_ONLY", html)
|
||||||
|
|
||||||
|
|
||||||
class SearchLabelTest(django.test.TestCase):
|
class SearchLabelTest(django.test.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user