From 14efff807817902893b773a2159739103f32798f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Mon, 8 Jun 2026 23:26:15 +0200 Subject: [PATCH] Fix filter stuff --- common/components/filters.py | 7 +- common/criteria.py | 22 +++---- games/static/base.css | 120 ++++++++++++++++++++++++++++++++++ games/static/js/filter_bar.js | 6 +- tests/test_filter_bars.py | 24 +++++++ tests/test_filters.py | 16 +++-- 6 files changed, 174 insertions(+), 21 deletions(-) diff --git a/common/components/filters.py b/common/components/filters.py index f95270b..2280239 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -128,9 +128,12 @@ def _split_modifier( first offered mode otherwise. """ default_match = match_modes[0][0] if match_modes else "" - if modifier in _PRESENCE_MODIFIERS: + if modifier in _PRESENCE_MODIFIERS or not match_modes: + # 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 and match_modes: + if modifier: return "", modifier return "", default_match diff --git a/common/criteria.py b/common/criteria.py index 4af3308..8c404b7 100644 --- a/common/criteria.py +++ b/common/criteria.py @@ -318,17 +318,17 @@ class _SetCriterion(_Criterion): if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS): return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q() if modifier == Modifier.INCLUDES_ALL: - # Logical AND of equalities ("related to every value"). NOTE: for a - # *multi-valued* relation this only behaves as "has all" when each - # equality lands on its own join — i.e. applied via chained - # ``.filter()`` calls or a ``pk__in`` subquery, not a single - # ``.filter(Q(rel=a) & Q(rel=b))`` (which would require one related - # row to equal both). M2M callers (e.g. PurchaseFilter.games) build - # that subquery; see PurchaseFilter._games_to_q. - q = Q() - for value in self.value: - q &= Q(**{field_name: value}) - return q + # INCLUDES_ALL ("related to all of these") is only meaningful for + # many-to-many fields. A naive Q(field=a) & Q(field=b) collapses + # to a single join requiring one through-row to equal both values + # (impossible), so the generic criterion layer cannot build a + # correct Q. M2M callers must supply their own Q builder at the + # filter level — see PurchaseFilter._games_to_q for the subquery + # pattern (chained .filter() calls + pk__in). + assert False, ( + "INCLUDES_ALL requires a filter-level Q builder for M2M fields. " + "See PurchaseFilter._games_to_q for the chained-subquery pattern." + ) raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}") @classmethod diff --git a/games/static/base.css b/games/static/base.css index 1980208..aa7a261 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -293,27 +293,85 @@ --leading-5: 20px; --radius-base: 12px; --color-body: var(--color-gray-600); + --color-body-subtle: var(--color-gray-500); --color-heading: var(--color-gray-900); + --color-fg-brand-subtle: var(--color-blue-200); --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-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: 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: var(--color-gray-50); --color-neutral-secondary-medium: 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-medium: var(--color-gray-100); --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: var(--color-blue-700); --color-brand-medium: var(--color-blue-200); --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-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-medium: var(--color-gray-100); + --color-default-subtle: var(--color-gray-200); --color-default: 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-accent: #7c3aed; } @@ -823,12 +881,18 @@ .start-0 { inset-inline-start: calc(var(--spacing) * 0); } + .end-1 { + inset-inline-end: calc(var(--spacing) * 1); + } .end-1\.5 { inset-inline-end: calc(var(--spacing) * 1.5); } .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -850,6 +914,9 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } + .bottom-1 { + bottom: calc(var(--spacing) * 1); + } .bottom-1\.5 { bottom: calc(var(--spacing) * 1.5); } @@ -1559,9 +1626,15 @@ text-align: center; } } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/2 { width: calc(1 / 2 * 100%); } + .w-2 { + width: calc(var(--spacing) * 2); + } .w-2\.5 { width: calc(var(--spacing) * 2.5); } @@ -1679,6 +1752,9 @@ .shrink-0 { flex-shrink: 0; } + .border-collapse { + border-collapse: collapse; + } .-translate-x-full { --tw-translate-x: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1695,6 +1771,10 @@ --tw-translate-x: 100%; 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 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -2080,12 +2160,18 @@ .bg-amber-50 { background-color: var(--color-amber-50); } + .bg-amber-500 { + background-color: var(--color-amber-500); + } .bg-amber-500\/15 { background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent); } } + .bg-black { + background-color: var(--color-black); + } .bg-black\/70 { background-color: color-mix(in srgb, #000 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2110,6 +2196,9 @@ background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); } } + .bg-dark-backdrop { + background-color: var(--color-dark-backdrop); + } .bg-dark-backdrop\/70 { background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2128,12 +2217,18 @@ .bg-gray-500 { background-color: var(--color-gray-500); } + .bg-gray-800 { + background-color: var(--color-gray-800); + } .bg-gray-800\/20 { background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { 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 { background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2263,6 +2358,18 @@ 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 { stroke: var(--color-default) !important; .dark & { @@ -2321,6 +2428,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -2547,6 +2657,9 @@ .text-balance { text-wrap: balance; } + .text-wrap { + text-wrap: wrap; + } .whitespace-nowrap { white-space: nowrap; } @@ -2682,6 +2795,9 @@ .line-through { text-decoration-line: line-through; } + .no-underline { + text-decoration-line: none; + } .no-underline\! { text-decoration-line: none !important; } @@ -2748,6 +2864,10 @@ -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 { + -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-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)); diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index 1ceeb40..217e80b 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -70,9 +70,11 @@ // Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the // pinned (Any)/(None) pseudo-options clears the value set, while the // match mode (INCLUDES/INCLUDES_ALL/EXCLUDES) governs how the include set - // matches. Fields without a match-mode select default to INCLUDES. + // matches. Fields without a data-match attribute have no match-mode select + // — the full modifier lives in data-modifier (e.g. enum/choice fields). var presence = widget.getAttribute("data-modifier"); - var match = widget.getAttribute("data-match") || "INCLUDES"; + var matchVal = widget.getAttribute("data-match"); + var match = matchVal || presence || "INCLUDES"; if (presence === "NOT_NULL" || presence === "IS_NULL") { filter[field] = { modifier: presence }; } else if (included.length > 0 || excluded.length > 0) { diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 8af2ce4..5dd6f4f 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -158,3 +158,27 @@ class FilterBarRenderingTest(TestCase): # for the double-escape bug the dedup fixed. self.assertIn(""status"", html) self.assertNotIn(""", html) + + def test_game_filter_bar_preserves_excludes_modifier(self): + """An enum field with an EXCLUDES modifier renders data-modifier correctly + so the JS roundtrip preserves the modifier (regression: _split_modifier + silently dropped non-presence modifiers when match_modes was None).""" + filter_json = json.dumps( + { + "status": { + "value": [{"id": "f", "label": "Finished"}], + "modifier": "EXCLUDES", + } + } + ) + html = str( + FilterBar( + filter_json=filter_json, preset_list_url="/l", preset_save_url="/s" + ) + ) + # The full modifier is stored on data-modifier when there's no match-mode + # select (enum/choice fields). No data-match attribute is present. + self.assertIn('data-modifier="EXCLUDES"', html) + self.assertNotIn("data-match=", html) + self.assertIn("Finished", html) + self.assertNoEscapedTags(html) diff --git a/tests/test_filters.py b/tests/test_filters.py index 43d755c..33ba93e 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -101,10 +101,12 @@ class TestChoiceCriterion: c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES) assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"]) - def test_includes_all(self): - """INCLUDES_ALL ANDs an equality per value (shared with MultiCriterion).""" + def test_includes_all_requires_filter_builder(self): + """INCLUDES_ALL cannot be built by the generic criterion layer — it + requires a filter-level Q builder (see PurchaseFilter._games_to_q).""" c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES_ALL) - assert c.to_q("status") == Q(status="f") & Q(status="p") + with pytest.raises(AssertionError, match="INCLUDES_ALL requires"): + c.to_q("status") def test_not_equals(self): c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS) @@ -136,10 +138,12 @@ class TestMultiCriterion: c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.EXCLUDES) assert c.to_q("game_id") == ~Q(game_id__in=[1]) & ~Q(game_id__in=[2]) - def test_includes_all(self): - """INCLUDES_ALL requires the row to relate to every value (M2M).""" + def test_includes_all_requires_filter_builder(self): + """INCLUDES_ALL cannot be built by the generic criterion layer — it + requires a filter-level Q builder (see PurchaseFilter._games_to_q).""" c = MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES_ALL) - assert c.to_q("games") == Q(games=1) & Q(games=2) + with pytest.raises(AssertionError, match="INCLUDES_ALL requires"): + c.to_q("games") def test_is_null(self): c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)