Files
member-console/internal/server/operator_entitlement_set_forms.go
T
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

464 lines
18 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"log/slog"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// The entitlement-set forms (spec entitlement-set-management "Operator
// entitlement set creation", "Operator entitlement set editing", "Operator
// entitlement set rule management", "The Rules table is the rule form";
// spec form-library; design D1, D2, D7, D8 of staged-rule-changes). Two
// declarations: the set itself (create page and the composite's edit form,
// one declaration in two modes) and the Rules table, one batch form of the
// rows family whose editing rows carry the declaration's controls in the
// table's own columns.
//
// This file is the two forms' home; the registry is only the index
// (design D1). It sits beside operator_entitlement_sets.go, which parses
// through both declarations and never through r.FormValue.
const entitlementSetFormName = "operator.entitlement-set"
// entitlementSetForm is registered at package init, so the registry is
// complete for the invariants test, the route test and the
// registered-forms table whether or not a handler has been built (the
// same choice lane A made for operator.product).
var entitlementSetForm = forms.Register(forms.FormSpec{
Name: entitlementSetFormName,
Kind: forms.KindCreate,
Family: forms.Stacked,
Method: "POST",
Path: "/partials/operator/entitlement-sets",
// The record side is a different route: the create page posts to the
// collection, the edit form puts to the record.
EditMethod: "PUT",
EditPath: "/partials/operator/entitlement-sets/{setID}",
// Both sides re-render the operator body: saving a set changes its
// rules section as well as the form, and a refusal must swap into the
// same target the success does (design D9, lesson L§17).
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
forms.RecordName(),
forms.RecordDescription(),
entitlementSetActiveField(),
},
Commit: "Create entitlement set",
CommitEdit: "Save changes",
WayOut: forms.LinkOut("Cancel", "/operator/entitlement-sets"),
})
// entitlementSetActiveField is the set's Active state, edit-only because a
// set is created active (finding FA-3). Unlike the product's edit-only
// fields, this one carries a Notice: entitlement-set-management's own
// scenario asks that the edit form say, at the field, why Active appears
// only there, since "why is there no toggle on the create page" is a
// question an operator would otherwise have to guess at (a set is always
// created active by design D21, and deactivating one is how a set that
// products or pools still reference gets retired without being deleted).
func entitlementSetActiveField() forms.Field {
f := forms.Field{
Name: "is_active",
Label: "Active",
Control: forms.Checkbox,
Value: "true",
Help: forms.Help("Active", "Inactive sets stay with the products that use them but cannot be chosen for a product."),
Notice: "A set is created active; deactivating here retires a set that products or pools still reference.",
}
return f.On(forms.EditOnly)
}
// entitlementSetCreateForm renders the create page's form: unbound on a
// first visit, submission-bound on a refusal.
func entitlementSetCreateForm(values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeUnbound
if errs.Any() {
mode = forms.ModeSubmission
}
return forms.Render(entitlementSetForm, forms.Binding{
Mode: mode,
Side: forms.CreateOnly,
Values: values,
Errors: errs,
})
}
// entitlementSetEditForm renders the composite's Details section, bound to
// the set or to a refused submission of it.
func entitlementSetEditForm(setID string, values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(entitlementSetForm.EditPath, setID)
if err != nil {
action = entitlementSetForm.Path
}
return forms.Render(entitlementSetForm, forms.Binding{
Mode: mode,
Side: forms.EditOnly,
Action: action,
Values: values,
Errors: errs,
})
}
// entitlementSetEditValues binds a set record to the declaration's fields.
func entitlementSetEditValues(v EntitlementSetViewModel) forms.Values {
values := forms.NewValues()
values.Set("name", v.Name)
values.Set("description", v.Description)
if f, ok := entitlementSetForm.Field("is_active"); ok {
values.SetBool(f, v.IsActive)
}
return values
}
// The rules form. One declaration, operator.entitlement-set.rules, of the
// batch kind in the rows family (design D1 of staged-rule-changes): the
// Rules table is the form's body, its editing rows carry the declaration's
// controls in the table's own columns, and the staged batch rides in one
// tray under the table. The Limit and "Per unit" fields declare a ShowIf
// condition on resourceKeyKind, a Hidden marker the handler fills from the
// selected key's database-derived kind, never from user choice, so the
// fields a key reveals are the declaration's own render
// (entitlement-set-management "the kind-dependent fields SHALL NOT be
// swapped in by a hand-built partial").
const (
entitlementSetRulesFormName = "operator.entitlement-set.rules"
// entitlementSetRulesRoute is where every row action, Undo and Discard
// all post; entitlementSetRulesApplyRoute is the commit's own path
// (design D2: a row action re-renders the section, the commit applies
// the batch and re-renders the body).
entitlementSetRulesRoute = "/partials/operator/entitlement-sets/{setID}/rules"
entitlementSetRulesApplyRoute = "/partials/operator/entitlement-sets/{setID}/rules/apply"
// entitlementSetRulesTarget is the section every row action swaps.
entitlementSetRulesTarget = "#entitlement-set-rules"
// entitlementSetRulesNewRow is the instance of the one editing row that
// belongs to no stored rule: the add form Add rule opens above the table.
entitlementSetRulesNewRow = "new"
entitlementSetRuleNumericKind = "numeric"
// entitlementSetRuleAddKeyPrefix names a delta that adds a rule the set
// does not have yet, whose key cannot be a rule id.
entitlementSetRuleAddKeyPrefix = "new:"
)
var entitlementSetRulesForm = forms.Register(forms.FormSpec{
Name: entitlementSetRulesFormName,
Kind: forms.KindBatch,
Family: forms.Rows,
Method: "POST",
Path: entitlementSetRulesRoute,
CommitAction: entitlementSetRulesApplyRoute,
// A row action re-renders the Rules section whole, from the submitted
// state, so two open editors and a staged change survive every press
// (design D2). The commit carries its own target, the operator body.
Target: entitlementSetRulesTarget,
Swap: "outerHTML",
Fields: []forms.Field{
entitlementSetRuleKeyField(),
entitlementSetRuleKindField(),
entitlementSetRuleValueField(),
entitlementSetRulePerUnitField(),
entitlementSetRulePolicyField(),
entitlementSetRuleNoteField(),
},
Commit: "Apply changes",
WayOut: forms.WayOut{Label: "Discard all"},
})
// The three verbs a delta carries.
const (
entitlementSetRuleVerbAdd = "add"
entitlementSetRuleVerbEdit = "edit"
entitlementSetRuleVerbRemove = "remove"
)
// The acts a row action posts. An empty act is the plain re-render the add
// form's resource-key select fires on change: the handler re-reads the whole
// submitted state and renders it back, which is how choosing a key reveals
// that kind's fields beside the select.
const (
entitlementSetRuleActAdd = "add"
entitlementSetRuleActEdit = "edit"
entitlementSetRuleActCancel = "cancel"
entitlementSetRuleActStage = "stage"
entitlementSetRuleActRemove = "remove"
entitlementSetRuleActUndo = "undo"
entitlementSetRuleActDiscard = "discard"
)
// entitlementSetRuleKeyField is the key-first picker, the add form's first
// field: choosing a key derives the rule's shape (entitlement-set-management
// "The form SHALL NOT expose a rule-type selector"). Its on-change swap posts
// the whole form and re-renders the section, so the kind-dependent fields
// appear beside the select that is already open and nothing typed elsewhere
// is lost. The concrete path is filled in per render
// (entitlementSetRulesSpecFor), because Field.Attrs is fixed at declaration
// time and cannot embed the set's id. One rung narrower than a select's
// natural third, so the add form's five columns and its two buttons fit one
// dense row at the laptop width; the table's cells ignore the width.
func entitlementSetRuleKeyField() forms.Field {
return forms.Field{
Name: "resource_key",
Label: "Resource",
Control: forms.Select,
RuntimeOptions: true,
Width: forms.WidthNarrower,
}
}
// entitlementSetRuleKindField is the ShowIf marker: the selected key's kind,
// resolved server-side from the database on every request, never read back
// for the write itself (the rule's type is derived fresh at the commit;
// entitlement-set-management "Rule type cannot be chosen by the client").
func entitlementSetRuleKindField() forms.Field {
return forms.Field{Name: "resource_key_kind", Label: "Resource key kind", Control: forms.Hidden, Optional: true}
}
// entitlementSetRuleValueField is the Limit control of an editing row, in
// the table's own Limit column. No hint: the column header labels it at
// desktop width and its own label does below lg.
func entitlementSetRuleValueField() forms.Field {
return forms.Field{
Name: "resource_value",
Label: "Limit",
Control: forms.Number,
Min: "0",
ShowIf: forms.ShowIf{Field: "resource_key_kind", Equals: []string{entitlementSetRuleNumericKind}},
}
}
// entitlementSetRulePerUnitField is the Per unit control, in the Per unit
// column. It carries no hint and no help icon of its own: the column
// header's help icon states what the flag does.
func entitlementSetRulePerUnitField() forms.Field {
return forms.Field{
Name: "resource_per_unit",
Label: "Per unit",
Control: forms.Checkbox,
Value: "true",
ShowIf: forms.ShowIf{Field: "resource_key_kind", Equals: []string{entitlementSetRuleNumericKind}},
}
}
// entitlementSetRulePolicyField is the reduction policy, a field of the rule
// edited beside its limit in the Rules table's own column (design D7; the
// maintainer's decision 1 of 2026-09-19 moved it out of the tray). Its
// options are per row, because a rule whose stored value is dormant offers
// that value as its own option; a boolean row carries no policy control at
// all, because no reduction policy acts on a rule that grants or withholds a
// capability. One rung narrower in the add form's dense row, as the key
// select is, for the same reason.
func entitlementSetRulePolicyField() forms.Field {
return forms.Field{
Name: "tier_reduction_policy",
Label: "Reduction policy",
Control: forms.Select,
RuntimeOptions: true,
Width: forms.WidthNarrower,
ShowIf: forms.ShowIf{Field: "resource_key_kind", Equals: []string{entitlementSetRuleNumericKind}},
}
}
// entitlementSetRuleNoteField is the batch's own field, rendered once in the
// tray before the commit.
func entitlementSetRuleNoteField() forms.Field {
return forms.Field{
Name: "note",
Label: "Note",
Control: forms.Text,
Optional: true,
MaxLen: 500,
Placement: forms.PlaceTray,
}
}
// The reduction policy's four stored values. Only clamp and force_reduce act
// at a rule commit, so only those two are offered on a new staging; block and
// defer are offered back to the rule that already carries one, selected, so
// the console rewrites a stored promise only when the operator picks another
// (design D7).
const (
entitlementSetRulePolicyClamp = "clamp"
entitlementSetRulePolicyForceReduce = "force_reduce"
entitlementSetRulePolicyBlock = "block"
entitlementSetRulePolicyDefer = "defer"
)
// entitlementSetRulePolicyDefault is where a new rule's select opens: the
// console writes an acting value while the column default stays the model's.
const entitlementSetRulePolicyDefault = entitlementSetRulePolicyClamp
// entitlementSetRulePolicyOptions are one row's options: the two acting
// values, plus the stored value when it is one of the dormant two.
func entitlementSetRulePolicyOptions(stored string) []forms.Option {
out := []forms.Option{
{Value: entitlementSetRulePolicyClamp, Label: "Clamp"},
{Value: entitlementSetRulePolicyForceReduce, Label: "Force reduce"},
}
switch stored {
case entitlementSetRulePolicyBlock:
out = append(out, forms.Option{Value: entitlementSetRulePolicyBlock, Label: "Block"})
case entitlementSetRulePolicyDefer:
out = append(out, forms.Option{Value: entitlementSetRulePolicyDefer, Label: "Defer"})
}
return out
}
// entitlementSetRulePolicyAccepted is what ParseBatchWith checks a submitted
// policy against: every value the page can render anywhere. Which of them one
// delta may actually carry is checked per delta against its own options,
// because a crafted submission can name a value no line offered.
func entitlementSetRulePolicyAccepted() []forms.Option {
return []forms.Option{
{Value: entitlementSetRulePolicyClamp, Label: "Clamp"},
{Value: entitlementSetRulePolicyForceReduce, Label: "Force reduce"},
{Value: entitlementSetRulePolicyBlock, Label: "Block"},
{Value: entitlementSetRulePolicyDefer, Label: "Defer"},
}
}
// entitlementSetRulePolicyOffered reports whether policy is one of the
// options a row whose stored value is stored would have offered.
func entitlementSetRulePolicyOffered(stored, policy string) bool {
for _, o := range entitlementSetRulePolicyOptions(stored) {
if o.Value == policy {
return true
}
}
return false
}
// entitlementSetRulePolicyLabel is a policy value as its select labels it
// ("Clamp", "Force reduce", "Block", "Defer"), which is how the table states
// it at rest; an unknown value reads as itself.
func entitlementSetRulePolicyLabel(value string) string {
for _, o := range entitlementSetRulePolicyAccepted() {
if o.Value == value {
return o.Label
}
}
return value
}
// entitlementSetRulePolicyWord is the same label lowered for a sentence
// ("reduction policy clamp becomes force reduce").
func entitlementSetRulePolicyWord(value string) string {
return strings.ToLower(entitlementSetRulePolicyLabel(value))
}
// entitlementSetRulesSpecFor is the declaration with the resource-key
// select's escape-hatch Attrs filled in for one set: it fires a scoped POST to
// the row-action route on change, carrying the whole form and swapping the
// Rules section back, so choosing a key reveals that row's numeric fields and
// puts the row under its kind's group. The registered declaration carries no
// Attrs, because a set's route cannot be known until it renders; Parse never
// reads Attrs, so the registered value is enough for parsing (the org-type
// card's per-render copy is the precedent).
func entitlementSetRulesSpecFor(setID string) forms.FormSpec {
spec := entitlementSetRulesForm
action, err := web.RouteURL(entitlementSetRulesRoute, setID)
if err != nil {
action = entitlementSetRulesRoute
}
swap := map[string]string{
"hx-post": action,
"hx-trigger": "change",
"hx-sync": "this:replace",
"hx-include": "closest form",
"hx-target": entitlementSetRulesTarget,
"hx-swap": "outerHTML",
}
fields := make([]forms.Field, 0, len(spec.Fields))
for _, f := range spec.Fields {
if f.Name == "resource_key" {
f.Attrs = swap
}
fields = append(fields, f)
}
spec.Fields = fields
return spec
}
// entitlementSetRulesAction is the concrete row-action path for one set, and
// entitlementSetRulesApplyAction the commit's.
func entitlementSetRulesAction(setID string) string {
action, err := web.RouteURL(entitlementSetRulesRoute, setID)
if err != nil {
return entitlementSetRulesRoute
}
return action
}
func entitlementSetRulesApplyAction(setID string) string {
action, err := web.RouteURL(entitlementSetRulesApplyRoute, setID)
if err != nil {
return entitlementSetRulesApplyRoute
}
return action
}
// entitlementSetRuleKeyOptions builds the resource-key select's options: a
// "Choose a resource key" placeholder (spec form-conventions "A select that
// needs a choice opens with a placeholder option") followed by every key
// still available for a new rule on this set (finding #21: a key that already
// has an active rule on this set is excluded, since offering it could only
// 422).
func entitlementSetRuleKeyOptions(keys []ResourceKeyOption) []forms.Option {
out := make([]forms.Option, 0, len(keys)+1)
out = append(out, forms.ChooseOption("a resource key"))
for _, k := range keys {
out = append(out, forms.Option{Value: k.ResourceKey, Label: k.DisplayName + " (" + k.ResourceKey + ")"})
}
return out
}
// entitlementSetRulesParseOptions is the option set ParseBatchWith reads a
// request through: every key the page could have offered, and every policy
// value any row could have offered.
func entitlementSetRulesParseOptions(keys []ResourceKeyOption) map[string][]forms.Option {
return map[string][]forms.Option{
"resource_key": entitlementSetRuleKeyOptions(keys),
"tier_reduction_policy": entitlementSetRulePolicyAccepted(),
}
}
// entitlementSetAvailableRuleKeys lists the resource keys usable for a new
// rule on setID: the catalog minus any key that already has an active rule on
// this set.
func (h *OperatorPartialsHandler) entitlementSetAvailableRuleKeys(ctx context.Context, setID string) []ResourceKeyOption {
ruled := map[string]bool{}
if rules, err := h.EntitlementsQ.GetActiveRulesBySetID(ctx, setID); err != nil {
h.Logger.Error("failed to list rules for key availability", slog.Any("error", err))
} else {
for _, rule := range rules {
if rule.ResourceKey.Valid {
ruled[rule.ResourceKey.String] = true
}
}
}
keys, err := h.EntitlementsQ.ListResourceKeys(ctx)
if err != nil {
h.Logger.Error("failed to list resource keys", slog.Any("error", err))
return nil
}
out := make([]ResourceKeyOption, 0, len(keys))
for _, rk := range keys {
if ruled[rk.ResourceKey] {
continue
}
out = append(out, ResourceKeyOption{ResourceKey: rk.ResourceKey, DisplayName: rk.DisplayName})
}
return out
}