Files
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
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.
2026-09-19 19:46:09 -05:00

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 }}