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:
Claude
2026-06-08 20:08:50 +00:00
committed by Lukáš Kucharczyk
parent 05534875d6
commit ba9b92d419
9 changed files with 419 additions and 72 deletions
+26 -1
View File
@@ -208,7 +208,9 @@ class FilterSelectComponentTest(unittest.TestCase):
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) # still pinned
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
) # still pinned
self.assertIn('data-prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self):
@@ -221,6 +223,29 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html)
MATCH_MODES = [("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]
def test_match_modes_render_native_select(self):
html = FilterSelect(field_name="games", match_modes=self.MATCH_MODES)
# A native <select> carries the include-set match mode; options are labels.
self.assertIn("data-search-select-match", html)
self.assertIn('value="INCLUDES_ALL"', html)
self.assertIn(">all</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(
field_name="games", match="INCLUDES_ALL", match_modes=self.MATCH_MODES
)
self.assertIn('data-match="INCLUDES_ALL"', html)
self.assertIn('value="INCLUDES_ALL" selected=""', html)
def test_no_match_modes_omits_select(self):
html = FilterSelect(field_name="status", options=[("f", "Finished")])
self.assertNotIn("data-search-select-match", html)
self.assertNotIn("data-match=", html)
class SearchLabelTest(django.test.TestCase):
@classmethod