#271. A grant on one ladder of a product that is a tier of several ladders keeps ending the subscription's tier on all of them, and the ladders the grant is not on stay empty until it ends; what changes is that every place an operator or a member looks now says so. A new test pins the console's behaviour the strings rest on: the subscription holds nothing anywhere, the other ladder holds no position, the grant delivers, and a reconcile of the subscription is queued, which pauses its billing when it pays for nothing else. The Issue grant form: an option now carries its own hint (forms.Option.Hint, rendered as data-hint, and hint-on-choice.js shows the chosen option's hint or the declared one). A product that would supersede a tier of several ladders names them: "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends." A product superseding a tier of one ladder keeps the existing warning. An option's hint is not held to the registry's 100-character cap, because the ladder names are the fact. The Not delivering badge on the organization page's Billing card and in the subscriptions list: the ladder's name rides the conferral shapes (ListConferralShapes selects it, Rung.LadderName), and the title adds "; nothing is delivered on {ladders}" for the ladders of the tier on which no live position exists when the page renders: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." A ladder something else holds is named in neither clause. The organization page reads the default pool's positions it already holds; the list reads them with one statement per page (ListLiveLadderIDsBySubscriptionIDs), and a failed read shows "Failed to load deliveries." as the other reads do. Whether a sentence says paused or continues is decided from the deciding displacements only, as the reconcile decides the pause, so a ladder a later subscription holds cannot turn a recorded pause into "Billing continues"; when a named holder is not a grant the sentence reads "Billing paused while other plans hold Duo and Wiki." The member's Products page: on each ladder the grant does not hold, the paid tier's card reads "Paused while Pro is granted on Wiki", or "Billing continues while Pro is granted on Wiki" until Stripe confirms the pause, and draws no control; the controls stay on the card of the ladder the grant holds. docs/models/plan-ladders-transitions.md, docs/plan-management.md and docs/stripe.md say the same. Tests: TestReconcile_AGrantOnOneLadderOfABundlePausesIt, TestSupersededLadders, TestLadderNamesNeedsEveryName, TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint, TestSubscriptionsListNamesEveryLadderOfABundle, TestOrgEnrollmentTwoLadderGolden (two states on their own World), TestGrantHeldLadder, TestPaidPlanCardOnAVacatedLadder, and new rows in TestDisplacedOnPage, TestDisplacementTitle, TestOrgEnrollmentIssueGrantSupersedeHint, TestOrgEnrollmentReadFailures and TestSubscriptionsListDisplacementReadFailures. TestMemberPlansGolden: the seven paid-plus-under-granted-pro states draw Duo's Plus card with the ladder line; the member and organization .json goldens gain the new fields. The grant-hint walkthrough's own-hint step is a subtest that skips, naming its precondition, because no demo state holds a subscription to a product of two ladders. Full suite on reset databases: 5143 passed, 15 skipped, none failed. make screens: no page changed; no demo state holds such a subscription. Hand captures cover the grant form, the badge and the member's cards. Unseen by any test: hint-on-choice.js not setting the line's text; only the browser walkthrough reads it, and its step skips. The hand captures show it. Breaks, all CAUGHT (44): - DisplacedProducts passes over a product of several rungs - displacedOnPage adds no emptied ladder (page rows) - displacedOnPage adds no emptied ladder (golden) - displacedOnPage names only the deciding ladder as held - groupDisplaced keeps an emptied ladder the group names as held - continuingSentence one subscription without the clause - displacedOnPage ignores live (page rows) - displacedOnPage ignores live (list) - pausedSentence uses pausedHolder whatever the holders - displacedOnPage takes grantsOnly over every holder the walk adds - organization page drops the positions read error from the badge - subscriptionDisplacementTitles ignores the live ladder read error - ListLiveLadderIDsBySubscriptionIDs answers no row - ListConferralShapes selects no name (list) - ListConferralShapes selects no name (golden) - RungsByProduct leaves LadderName empty - supersededLadders answers for a product whose superseded provisions each hold one ladder - supersededLadders keeps only the ladders the product is a tier of - ladderNames answers the names it read when one is missing - issuanceProductOptions leaves Option.Hint empty (page) - issuanceProductOptions leaves Option.Hint empty (golden) - issueGrantSupersedeHintOn answers for no ladders - selectOptions does not copy Hint - selectOptions copies Hint without ShowsHint - choiceHint answers the declared hint for a chosen option with its own - HintDefault left empty - HintDefault set when no option carries its own hint - ui_form_field.html without data-hint on the option - ui_form_field.html without data-hint-default on the line - grantHolds without its DefaultSourced test - grantHeldLadder walks rows in place of ladders - grantHeldLadder without its subscription test - setPaidUnderGrant draws the controls on a card with grantLadder - template plain paused branch without the ladder - template payment-due paused branch without the ladder - template moves-to paused branch without the ladder - template without the Billing continues branch - buildLadder without the empty-ladder case - buildLadder takes the heading's cancellation date from a card with grantLadder - pausedSentence one subscription without the clause - pausedSentence several subscriptions without the clause - continuingSentence a count of the account without the clause - continuingSentence several subscriptions without the clause - groupDisplaced leaves the emptied ladders unsorted
478 lines
19 KiB
Go
478 lines
19 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"html/template"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// The organization composite's two grant forms (spec
|
|
// plan-enrollment-administration "The composite's grant forms use
|
|
// imperative copy and help icons"; spec form-library, form-conventions;
|
|
// design D3, D8, D10). Issue grant is a stacked form in the panel the Grants
|
|
// card's header opens, one per page: a form is dense only while it fits on
|
|
// one line, and Issue grant needs two (grant-resumption D21). Manage is the
|
|
// grants ledger itself, a batch form of the rows family that commits row by
|
|
// row: Manage grows a grant's row by a card holding the controls, and the
|
|
// card's Save posts that grant's fields
|
|
// (findings FA-28, FA-29, FA-32, FA-36; grant-resumption D9, D15). Manage
|
|
// replaced Edit and Extend.
|
|
//
|
|
// This file is the forms' home; the registry is only the index (design
|
|
// D1). It sits beside operator_enrollment.go, which parses through the
|
|
// declarations and never through r.FormValue.
|
|
|
|
const (
|
|
issueGrantFormName = "operator.enrollment.grant.issue"
|
|
manageGrantFormName = "operator.enrollment.grant.manage"
|
|
// grantsLedgerRoute is where Manage's row actions post: Manage and
|
|
// Cancel re-render the ledger with a grant's row editing or with none,
|
|
// and write nothing. manageGrantRoute is the editing row's Save, the
|
|
// grant's own route (grant-resumption D15).
|
|
grantsLedgerRoute = "/partials/operator/organizations/{orgID}/grants/ledger"
|
|
manageGrantRoute = "/partials/operator/organizations/{orgID}/grants/{grantID}/manage"
|
|
// grantsLedgerTarget is the region a row action swaps: the ledger's
|
|
// table and nothing around it.
|
|
grantsLedgerTarget = "#grants-ledger"
|
|
// The two acts a ledger row posts. Any other act re-renders the ledger
|
|
// with no row editing.
|
|
grantActManage = "manage"
|
|
grantActCancel = "cancel"
|
|
)
|
|
|
|
// grantResumesField is the mark (grant-resumption D2, D9): whether the
|
|
// grant delivers again when the plan that replaced it ends. Issue grant
|
|
// carries it with its label above the box like every field's (D19), and
|
|
// Manage in the grant's card, the label above the box too. A checkbox
|
|
// is present or absent, so it needs no optional marker; its help icon
|
|
// carries what the label cannot, and it has no line under it (D9).
|
|
func grantResumesField() forms.Field {
|
|
return forms.Field{
|
|
Name: "resumes_after_supersession",
|
|
Label: "Resumes",
|
|
Control: forms.Checkbox,
|
|
Help: forms.Help("Resumes", grantResumesHelp),
|
|
}
|
|
}
|
|
|
|
// grantResumesHelp is the mark's help text, on the field and on the grants
|
|
// tables' Resumes column alike (grant-resumption D9).
|
|
const grantResumesHelp = "If a plan replaces this grant, the grant delivers again when that plan ends."
|
|
|
|
// issueGrantForm is the declaration for Issue grant: any published product,
|
|
// a quantity, an optional valid_until, a reason from the operator domain,
|
|
// and a free-text description (plan-enrollment-administration "Operator
|
|
// grant issuance"). 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 pattern operator_product_
|
|
// forms.go set).
|
|
//
|
|
// A stacked form in its panel (grant-resumption D21): one field per row,
|
|
// labels above, the box at the readable measure, its lead above the fields
|
|
// and the commit with Cancel under the last one. The library pairs the
|
|
// stacked family with the create kind, and Issue grant creates a grant; the
|
|
// sub-record kind it had is laid out dense, a single line Issue grant
|
|
// outgrew.
|
|
var issueGrantForm = forms.Register(forms.FormSpec{
|
|
Name: issueGrantFormName,
|
|
Kind: forms.KindCreate,
|
|
Family: forms.Stacked,
|
|
Method: "POST",
|
|
Path: "/partials/operator/organizations/{orgID}/grant/create",
|
|
// The whole composite re-renders: issuing a grant can change the
|
|
// pools' delivery listing, the ledger and the tier-changes history as
|
|
// well as the form, and a refusal must swap into the same target the
|
|
// success does (design D9, lesson L§17). Single instance per page, so
|
|
// no Binding.Target override is needed.
|
|
Target: "#operator-body",
|
|
Swap: "innerHTML",
|
|
Fields: []forms.Field{
|
|
issueGrantProductField(),
|
|
{
|
|
Name: "quantity",
|
|
Label: "Quantity",
|
|
Control: forms.Number,
|
|
Optional: true,
|
|
Min: "1",
|
|
Max: strconv.Itoa(maxGrantQuantity),
|
|
Help: forms.Help("Quantity", "Seats, for per-seat products."),
|
|
// A ghost example rather than a pre-filled value: the handler
|
|
// defaults a blank submission to 1 (plan-enrollment-administration,
|
|
// "a positive integer, defaulting to 1"), so leaving the field
|
|
// empty is itself a valid, common choice.
|
|
Placeholder: "1",
|
|
},
|
|
forms.GrantValidUntil(),
|
|
{
|
|
Name: "reason",
|
|
Label: "Reason",
|
|
Control: forms.Select,
|
|
Options: issueGrantReasonOptions(),
|
|
},
|
|
forms.GrantNote(),
|
|
grantResumesField(),
|
|
},
|
|
Commit: "Issue grant",
|
|
WayOut: forms.ClosePanel("Cancel", "issueGrantPanel"),
|
|
})
|
|
|
|
// issueGrantLead is Issue grant's message, above its fields
|
|
// (grant-resumption D21).
|
|
const issueGrantLead = "A tier product moves the pool to that tier, up or down."
|
|
|
|
// issueGrantSupersedeWarning is the Product field's hint line, shown while the
|
|
// chosen product carries the supersession marker (grant-pauses-subscription-billing
|
|
// Copy row W). The sentence states what the grant does to the subscription and
|
|
// names no view, because the operator's billing views only read
|
|
// (displaced-subscription-waits D4). The grant pauses the subscription's
|
|
// billing because the console pauses collection at Stripe while grants hold
|
|
// the ladders that keep every product the subscription pays for from being
|
|
// delivered (stripe-subscription-management, "A grant's hold pauses the
|
|
// subscription's collection at Stripe"), so the subscription is not charged
|
|
// for a period the grant covers. An option whose superseded tier is a tier of
|
|
// several ladders carries its own line that names them
|
|
// (issueGrantSupersedeHintOn).
|
|
const issueGrantSupersedeWarning = "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."
|
|
|
|
// manageGrantForm is the declaration for Manage (grant-resumption D15):
|
|
// everything about a grant that can change after issue, with the fields the
|
|
// issuance form uses, bound to the head's stored values. Product, Quantity
|
|
// and Reason are not here: a different product or quantity is a different
|
|
// grant.
|
|
//
|
|
// It is the grants ledger, a batch form of the rows family that commits row
|
|
// by row (D15): the table is its
|
|
// body, and the controls reach the card under the grant's own row. Manage and
|
|
// Cancel are row actions that post to Path and re-render the ledger with
|
|
// that row editing or with none, writing nothing; the editing row's Save
|
|
// posts the row's three fields to the grant's own route (CommitAction),
|
|
// whose handler compares the submitted Valid until with the stored one and
|
|
// extends the grant when it changed, or writes the mark and the note in
|
|
// place when it did not. Nothing is staged and there is no tray, and one row
|
|
// edits at a time. Revoke rides the editing row as the row's destructive act
|
|
// (FormView.RowConfirm, D9).
|
|
var manageGrantForm = forms.Register(forms.FormSpec{
|
|
Name: manageGrantFormName,
|
|
Kind: forms.KindBatch,
|
|
Family: forms.Rows,
|
|
RowCommit: true,
|
|
Method: "POST",
|
|
Path: grantsLedgerRoute,
|
|
CommitAction: manageGrantRoute,
|
|
// A row action re-renders the ledger region alone; the Save re-renders
|
|
// the composite (the binding's CommitTarget), as issuance and Revoke do.
|
|
Target: grantsLedgerTarget,
|
|
Swap: "outerHTML",
|
|
Fields: []forms.Field{
|
|
forms.GrantNote(),
|
|
manageGrantValidUntilField(),
|
|
grantResumesField(),
|
|
},
|
|
Commit: "Save",
|
|
})
|
|
|
|
// issueGrantReasonOptions is the grant-reason enum select's options: a
|
|
// disabled "Choose a reason" placeholder (spec form-conventions "A select
|
|
// that needs a choice opens with a placeholder option"), then the
|
|
// operator-selectable domain, never including 'default' (system-authored
|
|
// only) or 'trial' (superseded by 'evaluation' plus valid_until;
|
|
// plan-enrollment-administration "Operator cannot select grant_reason
|
|
// 'default'", "Time-boxed grant uses 'evaluation', never 'trial'").
|
|
func issueGrantReasonOptions() []forms.Option {
|
|
opts := make([]forms.Option, 0, len(grantReasonDomain)+1)
|
|
opts = append(opts, forms.ChooseOption("a reason"))
|
|
for _, v := range grantReasonDomain {
|
|
opts = append(opts, forms.Option{Value: v, Label: v})
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// issueGrantSupersedeHintOn is the hint of a product whose superseded
|
|
// subscription tier holds several ladders: Copy row G1, naming every ladder,
|
|
// because core.confer ends the tier on all of them whichever the grant is on
|
|
// (docs/models/plan-ladders-transitions.md, invariant 7). Billing pauses in
|
|
// this case: the product the subscription pays for is left alone and a grant
|
|
// holds the ladder that decides it. It answers "" for no ladders, and the
|
|
// option then shows issueGrantSupersedeWarning.
|
|
func issueGrantSupersedeHintOn(ladders []string) string {
|
|
if len(ladders) == 0 {
|
|
return ""
|
|
}
|
|
return "This grant would supersede a tier a subscription pays for on " + joinWithAnd(ladders) + "; its billing pauses until the grant ends."
|
|
}
|
|
|
|
// issuanceProductOptions builds the Product select's runtime options: a
|
|
// disabled "Choose a product" placeholder, then every issuable product,
|
|
// carrying forward the internal and supersession markers the composite
|
|
// already showed (finding FA-33 is unaddressed here; the marker's text
|
|
// equivalent is a tracked, not this lane's, fix).
|
|
func issuanceProductOptions(products []IssuanceProductOption) []forms.Option {
|
|
opts := make([]forms.Option, 0, len(products)+1)
|
|
opts = append(opts, forms.ChooseOption("a product"))
|
|
for _, p := range products {
|
|
label := p.Name
|
|
if p.IsInternal {
|
|
label += " [internal]"
|
|
}
|
|
if p.SupersedesSubscription {
|
|
label += " ⚠"
|
|
}
|
|
opts = append(opts, forms.Option{Value: p.ProductID, Label: label, ShowsHint: p.SupersedesSubscription, Hint: issueGrantSupersedeHintOn(p.SupersededLadders)})
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// issueGrantProductField is the grant's product picker plus the rule-less
|
|
// warning's live region, with Copy W as its hint, which waits for a product
|
|
// marked ⚠ (HintOnChoice) (design.md M1; plan-enrollment-administration "Issue
|
|
// grant names a product that provides nothing"), on the duplicate-name
|
|
// warning's precedent. The picker stays unfiltered and unpartitioned and the
|
|
// commit stays enabled: a set with no active rule is a state an operator may
|
|
// legitimately grant from.
|
|
func issueGrantProductField() forms.Field {
|
|
return forms.Field{
|
|
Name: "product_id",
|
|
Label: "Product",
|
|
Control: forms.Select,
|
|
RuntimeOptions: true,
|
|
HintOnChoice: true,
|
|
Hint: issueGrantSupersedeWarning,
|
|
Slot: "warning",
|
|
Attrs: map[string]string{
|
|
"hx-get": "/partials/operator/products/rule-check",
|
|
"hx-trigger": "change",
|
|
"hx-target": `#form-operator\.enrollment\.grant\.issue-product_id-warning`,
|
|
"hx-swap": "innerHTML",
|
|
},
|
|
}
|
|
}
|
|
|
|
// issueGrantFormOptions is the option map both the render and the parse
|
|
// read, so what the control offered and what the handler accepts are one
|
|
// list (the pattern operator_product_forms.go set for entitlement_set_id).
|
|
func issueGrantFormOptions(products []IssuanceProductOption) map[string][]forms.Option {
|
|
return map[string][]forms.Option{"product_id": issuanceProductOptions(products)}
|
|
}
|
|
|
|
// issueGrantFormView renders the Issue grant panel: mode is ModeRecord at
|
|
// rest (bound to empty values, so a fresh open shows nothing chosen and no
|
|
// error) and ModeSubmission on a refused submission, with every submitted
|
|
// value carried back (design D9). Its message is the lead alone; the
|
|
// supersession warning is the Product field's hint.
|
|
func issueGrantFormView(orgID string, products []IssuanceProductOption, mode forms.Mode, values forms.Values, errs *forms.Errors, warning template.HTML) forms.FormView {
|
|
action, err := web.RouteURL(issueGrantForm.Path, orgID)
|
|
if err != nil {
|
|
action = issueGrantForm.Path
|
|
}
|
|
var slots map[string]template.HTML
|
|
if warning != "" {
|
|
slots = map[string]template.HTML{"product_id": warning}
|
|
}
|
|
return forms.Render(issueGrantForm, forms.Binding{
|
|
Mode: mode,
|
|
Action: action,
|
|
Values: values,
|
|
Errors: errs,
|
|
Options: issueGrantFormOptions(products),
|
|
Slots: slots,
|
|
Message: template.HTML(issueGrantLead),
|
|
})
|
|
}
|
|
|
|
// manageValidUntilZoneAttr marks a Valid until field that is read in the
|
|
// zone the page displays it in. grant-valid-until-tz.js attaches no browser
|
|
// offset to a request from a form carrying it.
|
|
const manageValidUntilZoneAttr = "data-valid-until-zone"
|
|
|
|
// manageGrantValidUntilField is Valid until as Manage reads it: in the zone
|
|
// the page displays it in, the deployment's local zone, which is the zone of
|
|
// the prefilled stored bound and of every date on the page (forge issue
|
|
// #151 owns the general question). Read in the browser's zone, an edited
|
|
// value would keep a prefilled time of day shifted by the gap between the
|
|
// two zones, and an untouched one could not be told from an edited one.
|
|
func manageGrantValidUntilField() forms.Field {
|
|
f := forms.GrantValidUntil()
|
|
f.Attrs = map[string]string{manageValidUntilZoneAttr: "page"}
|
|
return f
|
|
}
|
|
|
|
// grantValidUntilLayout is the datetime-local field's value format, the
|
|
// minute precision the field submits.
|
|
const grantValidUntilLayout = "2006-01-02T15:04"
|
|
|
|
// grantValidUntilFieldValue is a stored bound as Manage's Valid until
|
|
// control renders it: minutes, in the server's zone, the zone Manage
|
|
// reads the field in (parseGrantValidUntilValue with no offset). Empty for
|
|
// an open-ended grant.
|
|
func grantValidUntilFieldValue(bound sql.NullTime) string {
|
|
if !bound.Valid {
|
|
return ""
|
|
}
|
|
return bound.Time.In(time.Local).Format(grantValidUntilLayout)
|
|
}
|
|
|
|
// manageGrantRestValues binds a grant's row, as Manage opens it, to the
|
|
// head's stored values: its bound, its note and its mark (the value a ticked
|
|
// box submits when it is set).
|
|
func manageGrantRestValues(g GrantViewModel) forms.Values {
|
|
values := forms.NewValues()
|
|
values.Set("valid_until", grantValidUntilFieldValue(g.ValidUntilAt))
|
|
values.Set("description", g.Note)
|
|
if g.Marked {
|
|
values.Set("resumes_after_supersession", grantResumesField().CheckboxValue())
|
|
}
|
|
return values
|
|
}
|
|
|
|
// revokeTrigger is the grant's Revoke, which its editing row offers after
|
|
// Save and Cancel and which opens the shared confirmation, on a live or
|
|
// on-hold grant (grant-resumption D9); nil where the row carries none.
|
|
// listQuery is the grants ledger's list state (grantsListQuery), carried on
|
|
// the URL as the row's Save carries it, so the answer re-renders the tab,
|
|
// page and search the operator was on.
|
|
func (g GrantViewModel) revokeTrigger(orgID, listQuery string) *forms.ConfirmTrigger {
|
|
if !g.RevokeOffered() {
|
|
return nil
|
|
}
|
|
url, err := web.RouteURL("/partials/operator/grants/{grantID}/revoke-and-transition", g.GrantID)
|
|
if err != nil {
|
|
url = "/partials/operator/grants/" + g.GrantID + "/revoke-and-transition"
|
|
}
|
|
if listQuery != "" {
|
|
url += "?" + listQuery
|
|
}
|
|
return &forms.ConfirmTrigger{
|
|
Label: "Revoke",
|
|
Title: "Revoke grant",
|
|
Body: g.RevokeBody(),
|
|
ConfirmLabel: "Revoke grant",
|
|
URL: url,
|
|
Target: "#operator-body",
|
|
Fields: map[string]string{"org_id": orgID},
|
|
}
|
|
}
|
|
|
|
// GrantsLedgerTable is the grants ledger's context
|
|
// (operator_enrollment_grants_table.html), the body of Manage's form: the
|
|
// bound form, whose RowField, RowAction, RowOpener, RowCommit and RowConfirm the cells
|
|
// call, the rows, and which tab's date columns to draw (grant-resumption
|
|
// D15, D20).
|
|
type GrantsLedgerTable struct {
|
|
Form forms.FormView
|
|
Rows []GrantLedgerRow
|
|
History bool
|
|
}
|
|
|
|
// Columns is the table's column count, which the editing row's card spans:
|
|
// Product, Reason, Issuance, Delivery, Resumes, Granted by, Qty, Activated,
|
|
// Valid until and Actions, and Ended on the History tab.
|
|
func (t GrantsLedgerTable) Columns() int {
|
|
if t.History {
|
|
return 11
|
|
}
|
|
return 10
|
|
}
|
|
|
|
// GrantLedgerRow is one grant's row in the ledger: the grant, whether
|
|
// Manage has turned the row into its editor, and, while it edits, the
|
|
// row's Revoke where D9 offers one.
|
|
type GrantLedgerRow struct {
|
|
GrantViewModel
|
|
Editing bool
|
|
Revoke *forms.RowActionView
|
|
// History is the tab's, which decides whether the row's cells carry
|
|
// Ended.
|
|
History bool
|
|
}
|
|
|
|
// grantsLedgerEdit is the grant whose row Manage edits and what its controls
|
|
// carry: the head's stored values when Manage opened it, a refused Save's
|
|
// submission otherwise. The zero value edits no row.
|
|
type grantsLedgerEdit struct {
|
|
GrantID string
|
|
Values forms.Values
|
|
// Errors are the refused Save's errors on the row's own fields;
|
|
// FormError is the refusal that belongs to none of them, in the form's
|
|
// own slot above the table.
|
|
Errors *forms.Errors
|
|
FormError string
|
|
// Focus is the control a successful render focuses: the row's first
|
|
// control after Manage, the row's Manage after Cancel.
|
|
Focus forms.Focus
|
|
}
|
|
|
|
// bindGrantsLedger binds Manage to the composite's grants, the rows the
|
|
// current tab, search and page show, with at most the one row edit names
|
|
// open, and renders the table into the form as its body through fragment
|
|
// (SafeTemplates.Fragment, or a test's own template set). The row actions
|
|
// and the Save carry the list state, so every answer renders the view the
|
|
// operator was on. A grant whose row offers no Manage is never opened.
|
|
func bindGrantsLedger(data *OrgEnrollmentData, edit grantsLedgerEdit, fragment func(name string, data any) template.HTML) {
|
|
listQuery := ""
|
|
if data.GrantsListQuery != "" {
|
|
listQuery = "?" + data.GrantsListQuery
|
|
}
|
|
action, err := web.RouteURL(grantsLedgerRoute, data.OrgID)
|
|
if err != nil {
|
|
action = grantsLedgerRoute
|
|
}
|
|
editing := ""
|
|
for _, g := range data.Grants {
|
|
if edit.GrantID != "" && g.GrantID == edit.GrantID && g.ManageOffered {
|
|
editing = g.GrantID
|
|
}
|
|
}
|
|
rows := map[string]forms.RowBinding{}
|
|
errs := forms.NewErrors()
|
|
if editing != "" {
|
|
commit, err := web.RouteURL(manageGrantRoute, data.OrgID, editing)
|
|
if err != nil {
|
|
commit = manageGrantRoute
|
|
}
|
|
rowErrs := edit.Errors
|
|
if rowErrs == nil {
|
|
rowErrs = forms.NewErrors()
|
|
}
|
|
rows[editing] = forms.RowBinding{Values: edit.Values, Errors: rowErrs, Action: commit + listQuery}
|
|
if edit.FormError != "" {
|
|
errs.Form(edit.FormError)
|
|
}
|
|
}
|
|
mode := forms.ModeRecord
|
|
if errs.Any() || rows[editing].Errors.Any() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
view := manageGrantForm.Bind(forms.Binding{
|
|
Mode: mode,
|
|
Action: action + listQuery,
|
|
Errors: errs,
|
|
Rows: rows,
|
|
Autofocus: edit.Focus,
|
|
// The Save re-renders the composite, as every grant act does; the
|
|
// response is #operator-body's contents, so it swaps them and keeps
|
|
// the container every later request targets.
|
|
CommitTarget: "#operator-body",
|
|
CommitSwap: "innerHTML",
|
|
})
|
|
table := GrantsLedgerTable{Form: view, History: data.GrantsNav.Facet == "history"}
|
|
for _, g := range data.Grants {
|
|
row := GrantLedgerRow{GrantViewModel: g, Editing: g.GrantID == editing && editing != "", History: table.History}
|
|
if row.Editing {
|
|
if t := g.revokeTrigger(data.OrgID, data.GrantsListQuery); t != nil {
|
|
revoke := view.RowConfirm(g.GrantID, *t)
|
|
row.Revoke = &revoke
|
|
}
|
|
}
|
|
table.Rows = append(table.Rows, row)
|
|
}
|
|
view.Body = fragment("operator_enrollment_grants_table.html", table)
|
|
data.ManageForm = view
|
|
}
|