The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
252 lines
15 KiB
HTML
252 lines
15 KiB
HTML
{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}}
|
|
{{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}}
|
|
|
|
{{/* Page anatomy: the form container (spec form-library "The form part
|
|
owns the container"; spec form-conventions; docs/design-system.md §8
|
|
"Forms"; design D4, D9, D10, D12, D20 to D23). Pipeline:
|
|
forms.Render(spec, binding) in internal/forms, built by the handler
|
|
from the form's declaration; templates write {{ template "form" .Form }}
|
|
and nothing else. This part and ui_form_field.html are the only
|
|
templates in the console that may render a <form> tag, a form label or
|
|
a form control; the raw-form-control lint rule holds every other
|
|
template to it.
|
|
|
|
The part owns the outer element (its id, its data-form id, its htmx
|
|
verb, target, swap, hx-disable, and role="search" on the Search kind's
|
|
native form, findings FA-53), the layout family, the form-level
|
|
error slot, and the commit row. The form-level slot renders on every
|
|
form and is empty when unused, so a refusal that belongs to no field
|
|
always has somewhere to land (findings FA-22, FA-26, FA-27); it
|
|
carries role="alert" so it is announced without a navigation.
|
|
|
|
A 200 body and a 422 body of one form are the same outer element, so
|
|
either swaps into the same target: htmx 4 swaps every response except
|
|
204 and 304, and its own instruction is to design error responses as
|
|
swap content (lesson L§17).
|
|
|
|
Every form whose refusal is a 422 carries `novalidate`, so pressing
|
|
the commit with an empty required field reaches the handler and swaps
|
|
the server's message in under the control. The browser's own bubble
|
|
is transient, unstyleable, uncapturable, and says nothing the
|
|
server's message does not; the constraint attributes stay on the
|
|
controls for assistive technology and for :invalid styling (GOV.UK
|
|
Frontend takes the same side).
|
|
|
|
Five families, one per kind (design D10 as corrected in round 4;
|
|
design D1 of staged-rule-changes added the fifth):
|
|
|
|
Stacked one control per row, in the section box the part owns, so
|
|
a page writes no card of its own and every stacked form is
|
|
the same width wherever it renders. The box is capped at
|
|
the readable measure and hugs its fields (maintainer,
|
|
2026-09-04: "Hugging definitely").
|
|
Dense one row, small controls, the commit at its end, no box
|
|
(the panel its page opened is already one). Its columns
|
|
align to the top under one label line, so a field's error
|
|
renders under its control and grows that column alone
|
|
while the commit stays level with the controls (design
|
|
D16 of staged-rule-changes, which retired D22's error
|
|
block below the row).
|
|
Bar one input group with a leading magnifier, no box: the
|
|
shape a search actually has. Its button is outline, because
|
|
a search is not a commit (D20).
|
|
Table one row per field, no box: the label and its hint in the
|
|
first column, the page's own pre-rendered cells next, the
|
|
control last, and one commit below the table (D21).
|
|
Rows the caller's own table of records as the body, its cells
|
|
holding the declaration's controls through the field part
|
|
and its rows' actions through the row-action part; the
|
|
staged batch rides as hidden inputs, and one tray under
|
|
the body, at the body's width, carries the batch's lines,
|
|
the tray's own fields and the commit.
|
|
|
|
Parsed into every template set by web.ParseUIPartials. */}}
|
|
{{ define "form" }}
|
|
{{ if eq .Box "card" }}
|
|
<div class="card app-form-box mb-3"><div class="card-body">{{ template "formTag" . }}</div></div>
|
|
{{ else if eq .Box "alert" }}
|
|
<div class="alert alert-secondary app-form-box mb-3" role="status">{{ template "formTag" . }}</div>
|
|
{{ else }}
|
|
{{ template "formTag" . }}
|
|
{{ end }}
|
|
{{ end }}
|
|
|
|
{{/* formTag is the form element itself, which the box wraps. */}}
|
|
{{ define "formTag" }}
|
|
<form id="{{ .ID }}" data-form="{{ .Name }}" data-form-kind="{{ .Kind }}" class="app-form{{ if .Dense }} app-form-dense{{ end }}{{ if .Table }} app-form-table{{ end }}{{ if .Bar }}{{ if .Wide }} app-form-bar-wide{{ else }} app-form-bar{{ end }}{{ end }}"{{ if .NoValidate }} novalidate{{ end }}{{ if .Native }} method="{{ .Method }}" action="{{ .Action }}" role="search"{{ end }} {{ .HX }}>
|
|
<div id="{{ .FormErrorID }}" class="app-form-error" role="alert">{{ if .FormError }}<div class="alert alert-danger mb-3">{{ .FormError }}</div>{{ end }}</div>
|
|
{{ if and (not .MessageAfter) (not .Rows) }}{{ template "formMessage" . }}{{ end }}
|
|
{{ if .Bar }}
|
|
{{/* Bar (design D20): one input group, no box. The magnifier is an
|
|
inline SVG because the console ships no icon font and the CSP
|
|
forbids fetching one. Carry-along state rides as hidden inputs,
|
|
and the one visible field's label is visually hidden beside the
|
|
region's own heading. */}}
|
|
{{ range .Fields }}{{ if eq .Control "hidden" }}{{ template "formField" . }}{{ else }}{{ template "formFieldLabel" . }}{{ end }}{{ end }}
|
|
<div class="input-group">
|
|
<span class="input-group-text"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.44.44.98.98l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.98-.98zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/></svg></span>
|
|
{{ range .Fields }}{{ if ne .Control "hidden" }}{{ template "formFieldControl" . }}{{ end }}{{ end }}
|
|
{{ template "formCommit" . }}
|
|
</div>
|
|
{{ else if .Table }}
|
|
{{/* Table (design D21): the label and its hint in the first column,
|
|
the page's own cells next (a source badge, an effective value),
|
|
the control last, and one commit below the table. A row's error
|
|
renders under its control in that cell, where it belongs, and so
|
|
does its ControlFooter (design D9, typed-config-keys): page-owned
|
|
content describing the stored override rather than the running
|
|
value (the integration settings page's Clear action and Pending
|
|
restart badge), on one line under the control. */}}
|
|
{{ range .Fields }}{{ if eq .Control "hidden" }}{{ template "formField" . }}{{ end }}{{ end }}
|
|
<div class="table-responsive">
|
|
<table class="table align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">{{ .LabelColumn }}</th>
|
|
{{ range .Columns }}<th scope="col">{{ . }}</th>{{ end }}
|
|
<th scope="col">{{ .ControlColumn }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{ range .Fields }}{{ if ne .Control "hidden" }}
|
|
<tr id="{{ .WrapperID }}" class="app-form-field">
|
|
<th scope="row" class="fw-normal">
|
|
{{ template "formFieldLabel" . }}
|
|
{{ if .Hint }}<div id="{{ .HintID }}" class="form-text">{{ .Hint }}</div>{{ end }}
|
|
{{ if .Notice }}<div id="{{ .NoticeID }}" class="form-text">{{ .Notice }}</div>{{ end }}
|
|
</th>
|
|
{{ range .Cells }}<td>{{ . }}</td>{{ end }}
|
|
<td>
|
|
{{ template "formFieldControl" . }}
|
|
{{ if .Error }}<div id="{{ .ErrorID }}" class="invalid-feedback d-block">{{ .Error }}</div>{{ end }}
|
|
{{ .ControlFooter }}
|
|
</td>
|
|
</tr>
|
|
{{ end }}{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-2 app-form-commit">{{ template "formCommit" . }}</div>
|
|
{{ else if .Rows }}
|
|
{{/* Rows (design D1, D10, D11 of staged-rule-changes): the caller's
|
|
table of records is the body, so a record's Limit sits in the
|
|
Limit column rather than in a panel somewhere else on the page.
|
|
The library writes the page state as hidden inputs (which editors
|
|
are open, and the whole staged batch), the body, and the tray.
|
|
|
|
Every control in the body comes from the field part through
|
|
FormView.RowField and every action from the row-action part
|
|
through FormView.RowAction, so the body carries no control of its
|
|
own and the anatomy lint holds it to that.
|
|
|
|
The tray is the family's commit region: the binding's message
|
|
(the caller's heading and its population lines), the staged
|
|
deltas as a bulleted list with one item per delta, so the batch
|
|
reads as a group of changes rather than as prose (maintainer,
|
|
2026-09-19), the tray's own fields, and the commit with the way
|
|
out. Each item is a flex row: the sentence grows, the delta's
|
|
fields follow, Undo sits at the right, and an error takes the
|
|
next line. The tray renders at the body's width rather than at
|
|
the stacked measure, because it reads as the table's own footing.
|
|
With nothing staged and nothing asked for, there is no tray at
|
|
all. */}}
|
|
{{ range .Hiddens }}<input type="hidden" name="{{ .Name }}" value="{{ .Value }}">
|
|
{{ end }}
|
|
{{ .Body }}
|
|
{{ if .Tray }}
|
|
<div class="alert alert-secondary app-form-box app-form-box-wide mt-3">
|
|
<div class="app-form-message mb-2"{{ if .TrayFocus }} tabindex="-1" autofocus{{ end }}>{{ .Message }}</div>
|
|
{{ if .Deltas }}
|
|
<ul class="app-form-tray-lines mb-2">
|
|
{{ range .Deltas }}
|
|
<li class="app-form-tray-line mb-2">
|
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
|
<span class="flex-grow-1">{{ .Line }}</span>
|
|
{{ range .Fields }}{{ template "formField" . }}{{ end }}
|
|
<span class="ms-auto">{{ template "formRowAction" .Undo }}</span>
|
|
{{ if .Error }}<div id="{{ .ErrorID }}" class="invalid-feedback d-block w-100">{{ .Error }}</div>{{ end }}
|
|
</div>
|
|
</li>
|
|
{{ end }}
|
|
</ul>
|
|
{{ end }}
|
|
{{ range .Fields }}{{ template "formField" . }}{{ end }}
|
|
<div class="d-flex flex-wrap gap-2 app-form-commit">{{ template "formRowAction" .Apply }}{{ template "formRowAction" .Discard }}</div>
|
|
</div>
|
|
{{ end }}
|
|
{{ else if .Dense }}
|
|
{{/* Dense (design D10; D16 of staged-rule-changes): one row, labels
|
|
above, small controls, each field in its declared column, the
|
|
commit at the row's end. The columns align to the top under one
|
|
label line, so an error under a control grows only its column; a
|
|
column with no label of its own (the commit, a checkbox) carries
|
|
the label line's height as padding (app.css .app-form-unlabeled)
|
|
and sits on the control line. */}}
|
|
<div class="row g-2 align-items-start app-form-dense-row">
|
|
{{ range .Fields }}{{ template "formField" . }}{{ end }}
|
|
<div class="col-auto app-form-commit app-form-unlabeled">{{ template "formCommit" . }}</div>
|
|
</div>
|
|
{{ else }}
|
|
{{/* Stacked (design D10): one control per row, the field column
|
|
capped at a readable width by app.css, so a create page and its
|
|
record's edit form look the same (finding FA-12). */}}
|
|
<div class="app-form-fields">
|
|
{{ range .Fields }}{{ template "formField" . }}{{ if and $.MessageAfter (eq .Name $.MessageAfter) }}{{ template "formMessage" $ }}{{ end }}{{ end }}
|
|
</div>
|
|
{{ if not .CommitHidden }}<div class="d-flex flex-wrap gap-2 app-form-commit">{{ template "formCommit" . }}</div>{{ end }}
|
|
{{ end }}
|
|
</form>
|
|
{{ end }}
|
|
|
|
{{/* formMessage is the form's own prose region: a preview's explanation of
|
|
what it is about to do, which belongs to the whole form rather than to
|
|
one field. A Confirm form renders the region always, empty at rest
|
|
(app.css collapses an empty one), because confirm-action-modal.js
|
|
writes each trigger's body text into it after the page has loaded.
|
|
FormSpec.MessageAfter moves the region below the field it names, for a
|
|
message that is the consequence of one control (design D23). */}}
|
|
{{ define "formMessage" }}
|
|
{{ if .MessageID }}<p id="{{ .MessageID }}" class="app-form-message mb-2">{{ .Message }}</p>{{ else if .Message }}<div class="app-form-message mb-2">{{ .Message }}</div>{{ end }}
|
|
{{ end }}
|
|
|
|
{{/* formRowAction is one posting control of a batch form (design D2 of
|
|
staged-rule-changes; spec form-library "A batch form renders the
|
|
caller's rows and one tray"): a row's Edit or Remove, a staged
|
|
delta's Undo, the tray's commit, the tray's way out. Pipeline:
|
|
forms.RowActionView, from FormView.RowAction in the caller's body or
|
|
from the view's own Apply, Discard and each delta's Undo.
|
|
|
|
It is a type="button" rather than a submit, because one form carries
|
|
several acts: the act and the row's key ride in hx-vals, the whole
|
|
form rides in hx-include, and the response swaps the form's own
|
|
region back, so every open editor's typed values and the whole
|
|
staged batch survive every press. The classes and the htmx
|
|
attributes are the view's, built in Go, so no template composes a
|
|
posting control by hand. */}}
|
|
{{ define "formRowAction" }}
|
|
<button type="button" class="{{ .Classes }}" {{ .HX }}>{{ if .Indicator }}<span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>{{ end }}{{ .Label }}</button>
|
|
{{ end }}
|
|
|
|
{{/* formCommit is the commit row (design D12, D20, D23; spec
|
|
form-conventions "The commit's size, width, order and in-flight state
|
|
are fixed"): the commit first, at its natural width, never stretched,
|
|
with its in-flight indicator inside it, then the way out as
|
|
btn-outline-secondary. Solid btn-secondary is not in the palette. An
|
|
always-open edit form declares no way out, because nothing is left by
|
|
cancelling (finding FA-19). The commit's classes come from the view
|
|
(FormView.CommitClasses), so a disabled commit is the same weight and
|
|
the same height as the enabled one it stands in for. */}}
|
|
{{ define "formCommit" }}
|
|
{{ if .CommitState }}{{ template "disabledControl" .CommitControl }}
|
|
{{ else }}
|
|
<button type="submit" class="{{ .CommitClasses }}"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>{{ .Commit }}</button>
|
|
{{ end }}
|
|
{{ with .WayOut }}{{ if .Shown }}
|
|
{{ if eq .Kind "link" }}{{ if .HXTarget }}<button type="button" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}" hx-get="{{ .URL }}" hx-target="{{ .HXTarget }}" hx-swap="{{ if .HXSwap }}{{ .HXSwap }}{{ else }}innerHTML{{ end }}">{{ .Label }}</button>{{ else }}<a href="{{ .URL }}" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}">{{ .Label }}</a>{{ end }}
|
|
{{ else if eq .Kind "close-panel" }}<button type="button" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}" data-bs-toggle="collapse" data-bs-target="#{{ .PanelID }}">{{ .Label }}</button>
|
|
{{ else if eq .Kind "dismiss-modal" }}<button type="button" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}" data-bs-dismiss="modal">{{ .Label }}</button>
|
|
{{ else if eq .Kind "discard" }}<button type="button" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}" hx-get="{{ .URL }}" hx-target="{{ $.Target }}" hx-select="{{ $.Target }}" hx-swap="outerHTML">{{ .Label }}</button>
|
|
{{ end }}
|
|
{{ end }}{{ end }}
|
|
{{ end }}
|