Own all form styling in components; remove form CSS from input.css

Form controls were styled "at a distance": Django renders bare
<input>/<select>/<textarea>/<label>, so input.css reached in with ID-scoped
#add-form descendant rules plus a global form *:disabled rule and .errorlist.
The #add-form ID specificity forced state rules to climb, needed
:not([data-search-select-search]) carve-outs, and broke on markup changes — it
surfaced as the add_purchase Name/related_game fields not reading as disabled.

Components now own all form styling via utilities on the elements themselves:

- PrimitiveWidgetsMixin stamps INPUT/SELECT/TEXTAREA_CLASS (incl. disabled:
  variants) onto native widgets by type, skipping SearchSelect (self-styled)
  and checkboxes.
- New FormFields(form, *, extras=...) renders label + control + errors + row
  layout with their own classes (replaces form.as_div()); the <form> owns its
  flex layout. extras appends a node into a named field's row (session
  timestamp buttons).
- AddForm/purchase/session render via FormFields; login too — a new
  LoginForm(PrimitiveWidgetsMixin, AuthenticationForm) styles its inputs and
  auth.py renders it via FormFields + a StyledButton (was as_table).
- input.css loses the entire #add-form block, the global :disabled rule, and
  .errorlist. State (disabled:) now lives on the element — no specificity wars,
  no carve-outs, robust to markup edits.

Tests: error rendering uses the component class (not .errorlist); add-form
labels/inputs carry their own classes; e2e login fixtures click the Login
button by text (submit is now a <button>); Name disabled cursor asserted.
CLAUDE.md documents the no-styling-at-a-distance + FormFields conventions.

513 passed; lint/format/ts-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 07:31:53 +02:00
parent b13cc3c324
commit 02798f8858
14 changed files with 217 additions and 257 deletions
+2
View File
@@ -189,6 +189,8 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
- **No styling-at-a-distance; elements carry their own classes**: `input.css` is document bootstrapping only (Tailwind import, theme, fonts, resets) — it contains **no form/component styling and no selectors that reach across the DOM** (`#id descendant`, `form input:disabled`, etc.) to style something a component owns. An element's appearance, **including state** (`disabled:`, `has-[:disabled]:`, `focus:`), comes from utility classes on that element, emitted by its component. This keeps state composable (no specificity wars) and robust to markup edits.
- **Forms render via `FormFields`/`AddForm`, never `form.as_div()`**: `FormFields(form, *, extras=...)` (in `primitives.py`) renders label + control + errors + row layout with their own classes; native controls get their classes from `PrimitiveWidgetsMixin` (`games/forms.py`, which stamps `INPUT/SELECT/TEXTAREA_CLASS` incl. `disabled:` variants by widget type, skipping SearchSelect + checkbox). Every form is on this path, including login (`LoginForm(PrimitiveWidgetsMixin, AuthenticationForm)`). `extras` appends a node into a named field's row (e.g. the session timestamp buttons).
- **Disabling composite widgets**: a composite widget (e.g. `SearchSelect`) carries its `id` on a wrapper `<div>`, which has no `disabled` state — setting `.disabled` on it is a no-op. Disable the inner control (for `SearchSelect`, the `[data-search-select-search]` input); the **component owns its disabled *look*** via `has-[:disabled]:` utilities on its container class, so callers toggle only the control's `disabled`, never styles.
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
+2
View File
@@ -53,6 +53,7 @@ from common.components.primitives import (
A,
AddForm,
ButtonGroup,
FormFields,
Checkbox,
CsrfInput,
Div,
@@ -114,6 +115,7 @@ __all__ = [
"randomid",
"A",
"AddForm",
"FormFields",
"StyledButton",
"ButtonGroup",
"Checkbox",
+76 -3
View File
@@ -600,6 +600,74 @@ def YearPicker(
)
# Form-field rendering. The element classes (label/error/checkbox-row + the
# controls, which carry their own classes via PrimitiveWidgetsMixin) live here,
# not in input.css — no selector reaches across the DOM to style a form.
_LABEL_CLASS = "mb-2.5 text-sm font-medium text-heading"
_FIELD_ERROR_CLASS = "mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]"
# Checkbox + its label share a row (unlike block fields), justified apart.
_CHECKBOX_ROW_CLASS = "flex flex-row justify-between mt-3"
def _field_errors(errors) -> Node | None:
"""Render a form/field ErrorList as a styled <ul>, or None if empty."""
items = [Li(children=[str(error)]) for error in errors]
if not items:
return None
return Ul(attributes=[("class", _FIELD_ERROR_CLASS)], children=items)
def FormFields(form, *, extras: dict[str, Node] | None = None) -> Node:
"""Render a Django form's fields as self-styled component rows.
Replaces ``form.as_div()`` so labels, errors, row layout, and the checkbox
row carry their own classes (no form styling in input.css). Native controls
get their classes from ``PrimitiveWidgetsMixin``; composite widgets
(SearchSelect) self-style. ``extras`` maps a field name to a node appended
inside that field's row (e.g. the session timestamp helper buttons).
"""
extras = extras or {}
rows: list[Node] = []
non_field = _field_errors(form.non_field_errors())
if non_field:
rows.append(non_field)
for field in form:
if field.is_hidden:
rows.append(Safe(str(field)))
continue
is_checkbox = getattr(field.field.widget, "input_type", None) == "checkbox"
label = Label(
attributes=[("for", field.id_for_label), ("class", _LABEL_CLASS)],
children=[str(field.label)],
)
control = Safe(str(field))
errors = _field_errors(field.errors)
extra = extras.get(field.name)
if is_checkbox:
children: list[Node] = [label, control]
if errors:
children.append(errors)
if extra:
children.append(extra)
rows.append(
Div(attributes=[("class", _CHECKBOX_ROW_CLASS)], children=children)
)
else:
children = []
if errors:
children.append(errors)
children.extend([label, control])
if extra:
children.append(extra)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
def AddForm(
form,
*,
@@ -610,18 +678,23 @@ def AddForm(
) -> Node:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
`fields` overrides the default ``FormFields(form)`` field markup (used by the
session form, which lays out its fields manually). `additional_row` holds
extra submit buttons rendered below the main Submit button. `submit_class`
is applied to the main Submit button (the session form passes "" to match
its original markup).
"""
field_markup = fields if fields is not None else Safe(form.as_div())
field_markup = fields if fields is not None else FormFields(form)
submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Element(
"form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
attributes=[
("method", "post"),
("enctype", "multipart/form-data"),
# Form owns its row layout (was the #add-form form{} rule in input.css).
("class", "flex flex-col gap-3"),
],
children=[
CsrfInput(request),
field_markup,
+3 -58
View File
@@ -127,19 +127,9 @@
}
}
/* Standalone form controls get a distinct disabled surface. The SearchSelect's
inner search box is excluded: it's a composite widget that owns its disabled
look on the wrapper (has-[:disabled] in _CONTAINER_CLASS), so painting the
inner input here would render a nested box inside the wrapper. */
form input:disabled:not([data-search-select-search]),
select:disabled,
textarea:disabled {
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
/* Form controls (incl. disabled state) and form-field markup (labels, errors,
rows) are styled by utilities on the elements themselves — see
PrimitiveWidgetsMixin and FormFields. No form styling lives here. */
#button-container button {
@apply mx-1;
@@ -181,51 +171,6 @@ textarea:disabled {
padding-left: 1em;
}
#add-form {
label + select, input, textarea {
@apply mt-1;
}
form {
@apply flex flex-col gap-3;
}
.form-row-button-group {
display: flex;
flex-direction: row;
@apply gap-0 p-0;
button {
@apply mr-0;
&:first-child {
@apply rounded-e-none;
}
&:nth-child(2) {
@apply rounded-none;
}
&:last-child {
@apply rounded-s-none;
}
}
}
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
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;
}
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;
}
textarea {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
}
:has(> label + input[type="checkbox"]) {
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
+1 -1
View File
@@ -9,7 +9,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
+1 -1
View File
@@ -106,7 +106,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
+11 -3
View File
@@ -20,7 +20,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
@@ -127,9 +127,14 @@ def test_add_purchase_type_toggles_disabled_fields(
name_input = page.locator("#id_name")
expect(name_input).to_be_disabled()
# The Name field (a plain input) self-styles its disabled state via the
# INPUT_CLASS disabled: variants — not a global rule. not-allowed is
# mode-independent, so it holds in light and dark.
assert name_input.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc")
expect(name_input).to_be_enabled()
assert name_input.evaluate("el => getComputedStyle(el).cursor") != "not-allowed"
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
@@ -168,7 +173,7 @@ def test_add_purchase_type_game_disables_related_game_search(
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
wrapper = page.locator("#id_related_game")
search = page.locator('#id_related_game [data-search-select-search]')
search = page.locator("#id_related_game [data-search-select-search]")
page.select_option("#id_type", "game")
expect(search).to_be_disabled()
@@ -177,7 +182,10 @@ def test_add_purchase_type_game_disables_related_game_search(
# The disabled inner input stays transparent (excluded from the global
# disabled-input surface) so the widget reads as one element, not a nested
# box. transparent is mode-independent, so this holds in light and dark.
assert search.evaluate("el => getComputedStyle(el).backgroundColor") == "rgba(0, 0, 0, 0)"
assert (
search.evaluate("el => getComputedStyle(el).backgroundColor")
== "rgba(0, 0, 0, 0)"
)
# The inner input carries the same not-allowed cursor as the wrapper, so the
# cursor doesn't flicker as the pointer crosses the widget.
assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
+44
View File
@@ -1,4 +1,5 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.db import transaction
from common.components import (
@@ -25,6 +26,30 @@ custom_datetime_widget = forms.DateTimeInput(
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
# Form controls self-style: these utility strings live on the elements (applied
# by PrimitiveWidgetsMixin), so there is no form styling in input.css and no
# selector reaching in to style them. The disabled: variants put state on the
# element too — no specificity wars, robust to markup changes.
_DISABLED_CONTROL = (
"disabled:bg-neutral-secondary-strong disabled:text-fg-disabled "
"disabled:cursor-not-allowed"
)
INPUT_CLASS = (
"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 "
f"px-3 py-2.5 shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
SELECT_CLASS = (
"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 "
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
TEXTAREA_CLASS = (
"bg-neutral-secondary-medium border border-default-medium text-heading "
"text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 "
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
@@ -60,6 +85,20 @@ class PrimitiveWidgetsMixin:
if isinstance(field, forms.BooleanField):
field.widget = PrimitiveCheckboxWidget()
# Maintain the field's explicit required status (usually False for booleans)
continue
widget = field.widget
# SearchSelect is a self-styled composite component; never stamp the
# native-control classes onto it.
if isinstance(widget, SearchSelectWidget):
continue
if isinstance(widget, forms.Select):
control_class = SELECT_CLASS
elif isinstance(widget, forms.Textarea):
control_class = TEXTAREA_CLASS
else:
control_class = INPUT_CLASS
existing = widget.attrs.get("class", "")
widget.attrs["class"] = f"{existing} {control_class}".strip()
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
@@ -420,3 +459,8 @@ class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
widgets = {
"timestamp": custom_datetime_widget,
}
class LoginForm(PrimitiveWidgetsMixin, AuthenticationForm):
"""Django's auth form with our primitive widget styling so login inputs
self-style like every other form (no styling-at-a-distance)."""
+13 -132
View File
@@ -2369,6 +2369,9 @@
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-3\.5 {
padding: calc(var(--spacing) * 3.5);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
@@ -3435,6 +3438,16 @@
cursor: not-allowed;
}
}
.disabled\:bg-neutral-secondary-strong {
&:disabled {
background-color: var(--color-neutral-secondary-strong);
}
}
.disabled\:text-fg-disabled {
&:disabled {
color: var(--color-fg-disabled);
}
}
.has-\[\:disabled\]\:cursor-not-allowed {
&:has(*:is(:disabled)) {
cursor: not-allowed;
@@ -4405,20 +4418,6 @@
border-left-color: var(--color-slate-500);
}
}
form input:disabled:not([data-search-select-search]), select:disabled, textarea:disabled {
cursor: not-allowed;
background-color: var(--color-neutral-secondary-strong);
color: var(--color-fg-disabled);
}
.errorlist {
margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 1);
width: 300px;
background-color: var(--color-red-600);
padding-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 3);
color: var(--color-slate-200);
}
#button-container button {
margin-inline: calc(var(--spacing) * 1);
}
@@ -4512,124 +4511,6 @@ form input:disabled:not([data-search-select-search]), select:disabled, textarea:
margin-bottom: 0.5em;
padding-left: 1em;
}
#add-form {
label + select, input, textarea {
margin-top: calc(var(--spacing) * 1);
}
form {
display: flex;
flex-direction: column;
gap: calc(var(--spacing) * 3);
}
.form-row-button-group {
display: flex;
flex-direction: row;
gap: calc(var(--spacing) * 0);
padding: calc(var(--spacing) * 0);
button {
margin-right: calc(var(--spacing) * 0);
&:first-child {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&:nth-child(2) {
border-radius: 0;
}
&:last-child {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
}
label {
margin-bottom: calc(var(--spacing) * 2.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
color: var(--color-heading);
}
input:not([type="checkbox"]):not([data-search-select-search]) {
margin-bottom: calc(var(--spacing) * 3);
display: block;
width: 100%;
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 2.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
&::placeholder {
color: var(--color-body);
}
&:focus {
border-color: var(--color-brand);
}
&:focus {
--tw-ring-color: var(--color-brand);
}
}
select {
width: 100%;
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 2.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
&::placeholder {
color: var(--color-body);
}
&:focus {
border-color: var(--color-brand);
}
&:focus {
--tw-ring-color: var(--color-brand);
}
}
textarea {
display: block;
width: 100%;
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
padding: calc(var(--spacing) * 3.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
&::placeholder {
color: var(--color-body);
}
&:focus {
border-color: var(--color-brand);
}
&:focus {
--tw-ring-color: var(--color-brand);
}
}
:has(> label + input[type="checkbox"]) {
margin-top: calc(var(--spacing) * 3);
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
position: fixed;
+15 -22
View File
@@ -4,29 +4,12 @@ registration/login.html)."""
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from common.components import CsrfInput, Div, Element, Input, Node, Safe
from common.components.primitives import Td, Tr
from common.components import CsrfInput, Div, Element, FormFields, Node, StyledButton
from common.layout import render_page
from games.forms import LoginForm
def _login_content(form, request) -> Node:
table = Element(
"table",
children=[
CsrfInput(request),
Safe(str(form.as_table())),
Tr(
children=[
Td(),
Td(
children=[
Input(type="submit", attributes=[("value", "Login")])
],
),
],
),
],
)
return Div(
[("class", "flex items-center flex-col")],
[
@@ -37,15 +20,25 @@ def _login_content(form, request) -> Node:
),
Element(
"form",
attributes=[("method", "post")],
children=[table],
attributes=[
("method", "post"),
("class", "flex flex-col gap-3 w-full max-w-sm"),
],
children=[
CsrfInput(request),
FormFields(form),
StyledButton([], "Login", type="submit"),
],
),
],
)
class LoginView(auth_views.LoginView):
"""Django's LoginView, but the page body is built in Python."""
"""Django's LoginView, but the page body is built in Python and the form is
our `LoginForm` so its inputs self-style like every other form."""
authentication_form = LoginForm
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
return render_page(
+2 -1
View File
@@ -180,7 +180,8 @@ def add_game(request: HttpRequest) -> HttpResponse:
),
),
title="Add New Game",
scripts=ModuleScript("dist/search_select.js") + ModuleScript("dist/add_game.js"),
scripts=ModuleScript("dist/search_select.js")
+ ModuleScript("dist/add_game.js"),
)
+2 -2
View File
@@ -22,6 +22,7 @@ from common.components import (
CsrfInput,
Div,
Element,
FormFields,
Fragment,
GameLink,
Icon,
@@ -32,7 +33,6 @@ from common.components import (
Node,
PriceConverted,
PurchasePrice,
Safe,
SelectionFields,
StyledButton,
TableRow,
@@ -296,7 +296,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
AddForm(
form,
request=request,
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
fields=Fragment(FormFields(form), _pricing_controls()),
additional_row=_purchase_additional_row(),
),
title="Add New Purchase",
+28 -33
View File
@@ -15,13 +15,13 @@ from common.components import (
AddForm,
ButtonGroup,
Div,
FormFields,
Fragment,
Icon,
ModuleScript,
NameWithIcon,
Node,
Popover,
Safe,
SearchField,
SessionDeviceSelector,
SessionTimestampButtons,
@@ -193,39 +193,34 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> Fragment:
"""Manual per-field layout for the session form.
def _timestamp_buttons(field_name: str) -> Node:
"""The now/toggle/copy helper buttons appended to a timestamp field's row."""
this_side = "start" if field_name == "timestamp_start" else "end"
other_side = "end" if field_name == "timestamp_start" else "start"
return SessionTimestampButtons(
class_="flex flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field_name, data_type="now", size="xs")["Set to now"],
StyledButton(data_target=field_name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field_name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons.
"""
rows: list[Node] = []
for field in form:
children: list[Node | str] = [
Safe(str(field.label_tag())),
Safe(str(field)),
]
if field.name in ("timestamp_start", "timestamp_end"):
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
SessionTimestampButtons(
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field.name, data_type="now", size="xs")[
"Set to now"
],
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field.name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
def _session_fields(form) -> Node:
"""Session form fields via the shared renderer, with timestamp helper
buttons appended to the two timestamp rows."""
return FormFields(
form,
extras={
name: _timestamp_buttons(name)
for name in ("timestamp_start", "timestamp_end")
},
)
@login_required
+17 -1
View File
@@ -128,6 +128,21 @@ class RenderedPagesTest(TestCase):
self.assertIn("dist/add_game.js", html)
self.assertIn("submit_and_redirect", html)
self.assertIn("Submit &amp; Create Purchase", html) # & correctly escaped
# Fields self-style: label + control carry their own classes (no #add-form
# / form CSS in input.css).
self.assertIn("mb-2.5 text-sm font-medium text-heading", html) # _LABEL_CLASS
self.assertIn("bg-neutral-secondary-medium", html) # INPUT_CLASS surface
self.assertNoEscapedTags(html)
def test_form_errors_render_with_component_class(self):
"""Invalid submits re-render field errors via FormFields' own class, not
Django's .errorlist (which no longer exists in the CSS)."""
# Non-empty but invalid (name is required) so the form binds and
# re-renders with errors — an empty {} POST is falsy and stays unbound.
response = self.client.post(reverse("games:add_game"), {"status": "u"})
html = response.content.decode()
self.assertIn("bg-red-600", html) # _FIELD_ERROR_CLASS
self.assertNotIn('class="errorlist"', html)
self.assertNoEscapedTags(html)
def test_add_purchase_form(self):
@@ -264,8 +279,9 @@ class RenderedPagesTest(TestCase):
"<!DOCTYPE html>", # full Page() layout
"Please log in to continue",
"csrfmiddlewaretoken",
'name="username"', # auth form fields rendered via FormFields
'type="submit"',
'value="Login"',
">Login<", # StyledButton submit (was an <input value="Login">)
"</html>",
]:
self.assertIn(marker, html)