Files
member-console/internal/server/operator_entitlement_set_rules_view.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

423 lines
15 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
)
// The Rules section's rendering (spec entitlement-set-management "The Rules
// table is the rule form", "One tray under the table carries the batch and
// its commit"; design D1, D10, D11, D13 of staged-rule-changes). The table is
// the form's body: the handler binds the declaration to the page's state,
// renders the table with that view in scope so its cells call RowField and
// RowAction, and hands the fragment back as the form's body.
//
// Every sentence the tray reads lives in
// operator_entitlement_set_rules_tray.html; this file carries only the facts
// it renders from, already formatted, so the copy stays greppable in one
// file.
// EntitlementSetRulesTable is the body fragment's context: the bound form,
// whose RowField and RowAction the body's controls call, and the rows to
// draw: the add form's row while Add rule holds it open, above the table and
// outside it, then the two groups inside the one table, the numeric rules
// under Limit and the boolean rules under Boolean (design D8, D10).
type EntitlementSetRulesTable struct {
Form forms.FormView
New *RuleViewModel
Limits []RuleViewModel
Booleans []RuleViewModel
}
// EntitlementSetRuleEditing is one editing row's context for the row part
// the table template defines: the form the row's controls come from and the
// row itself.
type EntitlementSetRuleEditing struct {
Form forms.FormView
Row RuleViewModel
}
// Editing pairs a row with the form for the editing-row part.
func (t EntitlementSetRulesTable) Editing(row RuleViewModel) EntitlementSetRuleEditing {
return EntitlementSetRuleEditing{Form: t.Form, Row: row}
}
// EntitlementSetRuleTrayView is the tray's own lines: the population once,
// the path the commit will take when the population is above the cap, and
// the rule type that reaches no pool.
type EntitlementSetRuleTrayView struct {
PoolCount int64
OrgCount int64
Pools string
Orgs string
// AboveCap says the commit will go on recomputing after it returns.
AboveCap bool
// ReachesNoPool is a quota or credit rule in the batch: the materializer
// folds neither, so no pool moves.
ReachesNoPool bool
}
// EntitlementSetRuleLineView is one staged delta's narrative on its tray
// line: the ledger's own sentences, without the History line the tray does
// not repeat.
type EntitlementSetRuleLineView struct {
Verb string
Boolean bool
Label string
LimitBefore string
LimitAfter string
LimitChanged bool
PerUnit bool
PerUnitBefore bool
PerUnitChanged bool
// PolicyBefore and PolicyAfter are the policies as the sentence says
// them ("clamp", "force reduce"), not as stored.
PolicyBefore string
PolicyAfter string
PolicyChanged bool
// Unchanged is a carried edit that moves nothing. Staging refuses such a
// row, so only a crafted batch carries one; its line says so and the
// commit refuses it with "Already applied.".
Unchanged bool
}
// RuleActionURL is where the section header's Add rule opener posts: the
// rules form's own row-action route, which it reaches from outside the form.
func (d EntitlementSetRulesData) RuleActionURL() string {
return entitlementSetRulesAction(d.EntitlementSet.SetID)
}
// entitlementSetRulesFormSelector is the form's id as a CSS selector, for the
// Add rule opener's hx-include: the declaration's name carries dots, which a
// selector would otherwise read as class separators.
func entitlementSetRulesFormSelector() string {
return "#" + strings.ReplaceAll("form-"+entitlementSetRulesFormName, ".", `\.`)
}
// entitlementSetRulesData builds the Rules section's whole view model from
// one render's state: the rows in their three shapes, the bound form with the
// table as its body, and the drain's two counts.
func (h *OperatorPartialsHandler) entitlementSetRulesData(rr entitlementSetRulesRender) EntitlementSetRulesData {
if rr.errs == nil {
rr.errs = forms.NewErrors()
}
if rr.open == nil {
rr.open = map[string]forms.RowBinding{}
}
newRow, limits, booleans := entitlementSetRuleRows(rr)
rows := make([]RuleViewModel, 0, len(limits)+len(booleans)+1)
if newRow != nil {
rows = append(rows, *newRow)
}
rows = append(append(rows, limits...), booleans...)
data := EntitlementSetRulesData{
EntitlementSet: rr.state.Set,
Rules: rows,
ResourceKeys: rr.state.Available,
PendingPools: rr.state.PendingPools,
FailedPools: rr.state.FailedPools,
}
data.Form = h.entitlementSetRulesFormView(rr, EntitlementSetRulesTable{New: newRow, Limits: limits, Booleans: booleans})
return data
}
// entitlementSetRulesFormView binds the declaration to this render's state
// and fills the form's body with the table. The body is rendered after the
// bind, because its cells call RowField on the view it sits inside.
func (h *OperatorPartialsHandler) entitlementSetRulesFormView(rr entitlementSetRulesRender, table EntitlementSetRulesTable) forms.FormView {
spec := entitlementSetRulesSpecFor(rr.state.SetID)
spec.CommitAction = entitlementSetRulesApplyAction(rr.state.SetID)
mode := forms.ModeRecord
if rr.refused() {
mode = forms.ModeSubmission
}
values := forms.NewValues()
values.Set("note", rr.note)
binding := forms.Binding{
Mode: mode,
Action: entitlementSetRulesAction(rr.state.SetID),
Values: values,
Errors: rr.errs,
Options: map[string][]forms.Option{
"resource_key": entitlementSetRuleKeyOptions(rr.state.Available),
"tier_reduction_policy": entitlementSetRulePolicyOptions(""),
},
Rows: entitlementSetRuleOpenRows(rr),
Staged: h.entitlementSetRuleStagedDeltas(rr),
Autofocus: rr.focus,
// The commit applies the batch, so it re-renders the operator body
// as every other commit on this page does; a row action re-renders
// the Rules section alone (design D2). The body's contents are
// swapped, never the container: the response is what #operator-body
// holds, and an outerHTML swap would destroy the element every
// later request on this page targets.
CommitTarget: "#operator-body",
CommitSwap: "innerHTML",
Target: entitlementSetRulesTarget,
}
if len(binding.Staged) > 0 {
binding.Message = h.Templates.Fragment("operator_entitlement_set_rules_tray.html", h.entitlementSetRuleTray(rr))
}
view := spec.Bind(binding)
table.Form = view
view.Body = h.Templates.Fragment("operator_entitlement_set_rules_table.html", table)
return view
}
// entitlementSetRuleOpenRows gives each open editor the policy options its
// own rule allows: the two acting values, plus the stored value when it is a
// dormant one, so the row rewrites a stored promise only when the operator
// picks another (design D7). A new row offers the acting two.
func entitlementSetRuleOpenRows(rr entitlementSetRulesRender) map[string]forms.RowBinding {
rows := make(map[string]forms.RowBinding, len(rr.open))
for instance, rb := range rr.open {
stored := ""
if rule, ok := rr.state.ruleFor(instance); ok {
stored = rule.TierReductionPolicy
}
rb.Options = map[string][]forms.Option{
"tier_reduction_policy": entitlementSetRulePolicyOptions(stored),
}
rows[instance] = rb
}
return rows
}
// entitlementSetRuleStagedDeltas turns the validated batch into the library's
// own staged values: the hidden fields it writes, the line the tray renders
// and the refusal on that line. A line carries no control of its own: the
// policy is a field of the row, so the tray states and undoes only.
func (h *OperatorPartialsHandler) entitlementSetRuleStagedDeltas(rr entitlementSetRulesRender) []forms.StagedDelta {
if len(rr.staged) == 0 {
return nil
}
out := make([]forms.StagedDelta, 0, len(rr.staged))
for _, d := range rr.staged {
out = append(out, forms.StagedDelta{
Verb: d.Verb,
Key: d.Key,
Values: d.Values,
Line: h.Templates.Fragment("entitlementSetRuleLine", entitlementSetRuleLine(d)),
Error: d.Error,
})
}
return out
}
// entitlementSetRuleLine is one delta's narrative facts.
func entitlementSetRuleLine(d entitlementSetRuleDelta) EntitlementSetRuleLineView {
view := EntitlementSetRuleLineView{
Verb: d.Verb,
Boolean: d.Boolean,
Label: d.Label,
LimitAfter: d.limit(),
PerUnit: d.perUnit(),
PolicyAfter: entitlementSetRulePolicyWord(d.policy()),
}
if d.Stored != nil {
view.LimitBefore = storedLimit(*d.Stored)
view.PerUnitBefore = d.Stored.ResourcePerUnit.Valid && d.Stored.ResourcePerUnit.Bool
view.PolicyBefore = entitlementSetRulePolicyWord(d.Stored.TierReductionPolicy)
}
view.LimitChanged = view.LimitBefore != view.LimitAfter
view.PerUnitChanged = view.PerUnitBefore != view.PerUnit
view.PolicyChanged = view.PolicyBefore != view.PolicyAfter
if d.Verb == entitlementSetRuleVerbRemove {
// A removal states the rule as it stands, not as it was proposed.
view.LimitAfter = view.LimitBefore
}
view.Unchanged = d.Verb == entitlementSetRuleVerbEdit &&
!view.LimitChanged && !view.PerUnitChanged && !view.PolicyChanged
return view
}
// entitlementSetRuleTray reads the carrying population once, which is the one
// fact the tray states about the pools; it runs no dry run, because nothing
// the tray renders depends on one (design D5).
func (h *OperatorPartialsHandler) entitlementSetRuleTray(rr entitlementSetRulesRender) EntitlementSetRuleTrayView {
view := EntitlementSetRuleTrayView{}
for _, d := range rr.staged {
if d.ReachesNoPool {
view.ReachesNoPool = true
}
}
counts, err := h.EntitlementsQ.CountPoolsCarryingSet(rr.context(), rr.state.SetID)
if err != nil {
h.Logger.Error("failed to count the pools carrying the set", slog.Any("error", err), slog.String("set_id", rr.state.SetID))
return view
}
view.PoolCount = counts.PoolCount
view.OrgCount = counts.OrgCount
view.Pools = formatCount(counts.PoolCount)
view.Orgs = formatCount(counts.OrgCount)
view.AboveCap = counts.PoolCount > entitlements.SyncCap()
return view
}
// entitlementSetRuleRows draws the rows: the add form's row while Add rule
// holds it open, which stays above the table whatever key it names, and the
// two groups, the numeric rules under Limit and the boolean rules under
// Boolean (design D10). In each group the staged additions come first, then
// the stored rules in the query's order (design D8).
func entitlementSetRuleRows(rr entitlementSetRulesRender) (newRow *RuleViewModel, limits, booleans []RuleViewModel) {
place := func(row RuleViewModel) {
if row.RuleType == "boolean" {
booleans = append(booleans, row)
return
}
limits = append(limits, row)
}
if rb, open := rr.open[entitlementSetRulesNewRow]; open {
kind := rb.Values.String("resource_key_kind")
newRow = &RuleViewModel{
Instance: entitlementSetRulesNewRow,
RuleType: "limit",
IsNew: true,
Editing: true,
Numeric: kind == entitlementSetRuleNumericKind,
}
if kind == "boolean" {
newRow.RuleType = "boolean"
}
}
staged := map[string]entitlementSetRuleDelta{}
for _, d := range rr.staged {
if d.Verb != entitlementSetRuleVerbAdd {
staged[d.RuleID] = d
continue
}
row := RuleViewModel{
Instance: entitlementSetRulesNewRow,
ResourceKey: d.ResourceKey,
DisplayName: d.Label,
RuleType: "limit",
IsNew: true,
Staged: true,
Verb: d.Verb,
DeltaKey: d.Key,
}
if row.DisplayName == row.ResourceKey {
row.DisplayName = ""
}
if d.Boolean {
row.RuleType = "boolean"
} else {
row.NewLimit = d.limit()
row.NewPerUnit = perUnitLabel(d.perUnit())
row.NewPolicy = entitlementSetRulePolicyLabel(d.policy())
}
place(row)
}
for _, rule := range rr.state.Rules {
row := RuleViewModel{
RuleID: rule.RuleID,
Instance: rule.RuleID,
RuleType: rule.RuleType,
ResourceKey: rule.ResourceKey.String,
DisplayName: rr.state.label(rule.ResourceKey.String),
ResourceValue: rule.ResourceValue.Int64,
ResourcePerUnit: rule.ResourcePerUnit.Valid && rule.ResourcePerUnit.Bool,
StackingPolicy: rule.StackingPolicy.String,
IsActive: rule.IsActive,
}
if rule.RuleType != "boolean" {
row.Policy = entitlementSetRulePolicyLabel(rule.TierReductionPolicy)
}
if row.DisplayName == row.ResourceKey {
row.DisplayName = ""
}
switch d, isStaged := staged[rule.RuleID]; {
case isStaged:
row.Staged = true
row.Verb = d.Verb
row.DeltaKey = d.Key
row.Removed = d.Verb == entitlementSetRuleVerbRemove
stageRuleRowValues(&row, d, rule)
default:
if rb, open := rr.open[rule.RuleID]; open {
row.Editing = true
row.Numeric = rb.Values.String("resource_key_kind") == entitlementSetRuleNumericKind
}
}
place(row)
}
return newRow, limits, booleans
}
// splitRuleRows sorts rows already drawn into the two groups by kind, for a
// caller that holds a flat list (a render test's fixture).
func splitRuleRows(rows []RuleViewModel) (limits, booleans []RuleViewModel) {
for _, row := range rows {
if row.RuleType == "boolean" {
booleans = append(booleans, row)
continue
}
limits = append(limits, row)
}
return limits, booleans
}
// stageRuleRowValues fills a staged row's struck old values and its new ones.
// A removal strikes the resource name instead and keeps the rule's values as
// they stand.
func stageRuleRowValues(row *RuleViewModel, d entitlementSetRuleDelta, rule entitlements.EntitlementSetRule) {
if rule.RuleType == "boolean" {
return
}
stored := storedLimit(rule)
storedPerUnit := rule.ResourcePerUnit.Valid && rule.ResourcePerUnit.Bool
storedPolicy := entitlementSetRulePolicyLabel(rule.TierReductionPolicy)
if d.Verb == entitlementSetRuleVerbRemove {
row.NewLimit = stored
row.NewPerUnit = perUnitLabel(storedPerUnit)
row.NewPolicy = storedPolicy
return
}
row.NewLimit = d.limit()
row.NewPerUnit = perUnitLabel(d.perUnit())
row.NewPolicy = entitlementSetRulePolicyLabel(d.policy())
if row.NewLimit != stored {
row.OldLimit = stored
}
if d.perUnit() != storedPerUnit {
row.OldPerUnit = perUnitLabel(storedPerUnit)
}
if row.NewPolicy != storedPolicy {
row.OldPolicy = storedPolicy
}
}
func perUnitLabel(on bool) string {
if on {
return "Yes"
}
return "No"
}
// renderEntitlementSetRulesSection answers a row action: the Rules section
// whole, from the submitted state, at 200 or at 422 when something was
// refused.
func (h *OperatorPartialsHandler) renderEntitlementSetRulesSection(w http.ResponseWriter, rr entitlementSetRulesRender) {
data := h.entitlementSetRulesData(rr)
if rr.refused() {
w.WriteHeader(http.StatusUnprocessableEntity)
}
h.Templates.Render(w, "operator_entitlement_set_rules.html", data)
}