From 74dffaeae4c05d5a7a2b3c6aa2a4df67b15ffc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 9 Jun 2026 20:46:00 +0200 Subject: [PATCH] feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin --- common/input.css | 3 - games/forms.py | 42 ++++++++++-- games/static/base.css | 137 --------------------------------------- tests/test_components.py | 24 +++++++ 4 files changed, 59 insertions(+), 147 deletions(-) diff --git a/common/input.css b/common/input.css index 74fcab6..a59fd09 100644 --- a/common/input.css +++ b/common/input.css @@ -209,9 +209,6 @@ textarea:disabled { input:not([type="checkbox"]):not([data-search-select-search]) { @apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body; } - input[type="checkbox"] { - @apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft; - } select { @apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body; } diff --git a/games/forms.py b/games/forms.py index 1e43b90..c5fad57 100644 --- a/games/forms.py +++ b/games/forms.py @@ -8,6 +8,7 @@ from common.components import ( SearchSelectOption, searchselect_selected, ) +from common.components.primitives import Checkbox from games.models import ( Device, Game, @@ -25,6 +26,33 @@ custom_datetime_widget = forms.DateTimeInput( autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) +class PrimitiveCheckboxWidget(forms.CheckboxInput): + """Adapts Django's CheckboxInput to use our Checkbox component.""" + def render(self, name, value, attrs=None, renderer=None): + final_attrs = self.build_attrs(self.attrs, attrs) + checked = self.check_test(value) + attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")] + + # Django uses boolean values differently for checkboxes, we omit value if empty + return str(Checkbox( + name=name, + label=None, + checked=checked, + value=str(value) if value else "1", + attributes=attributes + )) + + +class PrimitiveWidgetsMixin: + """Automatically applies primitive custom widgets to native Django form fields.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name, field in self.fields.items(): + if isinstance(field, forms.BooleanField): + field.widget = PrimitiveCheckboxWidget() + # Maintain the field's explicit required status (usually False for booleans) + + class MultipleGameChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj) -> str: return obj.search_label @@ -128,7 +156,7 @@ class SearchSelectMultiple(SearchSelectWidget): return data.get(name) -class SessionForm(forms.ModelForm): +class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm): game = SingleGameChoiceField( queryset=Game.objects.order_by("sort_name"), widget=SearchSelectWidget( @@ -212,7 +240,7 @@ class RelatedPurchaseChoiceField(forms.ModelChoiceField): return name or obj.standardized_name -class PurchaseForm(forms.ModelForm): +class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["platform"].queryset = Platform.objects.order_by("name") @@ -305,7 +333,7 @@ class GameModelChoiceField(forms.ModelChoiceField): return obj.sort_name -class GameForm(forms.ModelForm): +class GameForm(PrimitiveWidgetsMixin, forms.ModelForm): platform = forms.ModelChoiceField( queryset=Platform.objects.order_by("name"), required=False, @@ -329,7 +357,7 @@ class GameForm(forms.ModelForm): widgets = {"name": autofocus_input_widget} -class PlatformForm(forms.ModelForm): +class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm): class Meta: model = Platform fields = [ @@ -340,14 +368,14 @@ class PlatformForm(forms.ModelForm): widgets = {"name": autofocus_input_widget} -class DeviceForm(forms.ModelForm): +class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm): class Meta: model = Device fields = ["name", "type"] widgets = {"name": autofocus_input_widget} -class PlayEventForm(forms.ModelForm): +class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm): game = SingleGameChoiceField( queryset=Game.objects.order_by("sort_name"), widget=SearchSelectWidget( @@ -382,7 +410,7 @@ class PlayEventForm(forms.ModelForm): return session -class GameStatusChangeForm(forms.ModelForm): +class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm): class Meta: model = GameStatusChange fields = [ diff --git a/games/static/base.css b/games/static/base.css index 1383d33..4761651 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -293,85 +293,26 @@ --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; } @@ -881,18 +822,12 @@ .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%); } @@ -914,9 +849,6 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } - .bottom-1 { - bottom: calc(var(--spacing) * 1); - } .bottom-1\.5 { bottom: calc(var(--spacing) * 1.5); } @@ -1629,15 +1561,9 @@ 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); } @@ -1755,9 +1681,6 @@ .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); @@ -1774,10 +1697,6 @@ --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); @@ -2169,18 +2088,12 @@ .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)) { @@ -2205,9 +2118,6 @@ 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)) { @@ -2226,18 +2136,12 @@ .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)) { @@ -2367,18 +2271,6 @@ 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 & { @@ -2437,9 +2329,6 @@ .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); } @@ -2666,9 +2555,6 @@ .text-balance { text-wrap: balance; } - .text-wrap { - text-wrap: wrap; - } .whitespace-nowrap { white-space: nowrap; } @@ -2804,9 +2690,6 @@ .line-through { text-decoration-line: line-through; } - .no-underline { - text-decoration-line: none; - } .no-underline\! { text-decoration-line: none !important; } @@ -2873,10 +2756,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,); 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)); @@ -4533,22 +4412,6 @@ form input:disabled, select:disabled, textarea:disabled { --tw-ring-color: var(--color-brand); } } - input[type="checkbox"] { - height: calc(var(--spacing) * 4); - width: calc(var(--spacing) * 4); - border-radius: var(--radius-xs); - border-style: var(--tw-border-style); - border-width: 1px; - border-color: var(--color-default-medium); - background-color: var(--color-neutral-secondary-medium); - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - &:focus { - --tw-ring-color: var(--color-brand-soft); - } - } select { width: 100%; border-radius: var(--radius-base); diff --git a/tests/test_components.py b/tests/test_components.py index 0731755..e92bca4 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -852,5 +852,29 @@ class ComponentPrimitivesTest(SimpleTestCase): self.assertIn("Option A", html) +class PrimitiveWidgetsTest(SimpleTestCase): + def test_mixin_applies_widget_to_boolean_fields_only(self): + from django import forms + from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin + + class DummyForm(PrimitiveWidgetsMixin, forms.Form): + agree = forms.BooleanField(required=False) + name = forms.CharField(required=False) + + form = DummyForm() + self.assertIsInstance(form.fields["agree"].widget, PrimitiveCheckboxWidget) + self.assertNotIsInstance(form.fields["name"].widget, PrimitiveCheckboxWidget) + + def test_primitive_checkbox_widget_renders_headless(self): + from games.forms import PrimitiveCheckboxWidget + widget = PrimitiveCheckboxWidget() + html = widget.render(name="agree", value=True) + self.assertNotIn("