// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "html/template" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) // The plan-ladder forms (spec plan-ladder-management; spec form-library; // design D2, D5, D8, D10). Four declarations: the ladder itself (create // page and the composite's edit form, one declaration in two modes), the // tier-add dense sub-record form, and the two preview-then-commit forms // (reorder, removal) that share the preview idiom (design D10; finding // FA-13): one alert tone, disposition radios at the default size, a // filled "Apply change" commit and a "Discard" way out. // // This file is the four forms' home; the registry is only the index // (design D1). It sits beside operator_plan_ladders.go, which parses // through every declaration and never through r.FormValue. const planLadderFormName = "operator.plan-ladder" var planLadderForm = forms.Register(forms.FormSpec{ Name: planLadderFormName, Kind: forms.KindCreate, Family: forms.Stacked, Method: "POST", Path: "/partials/operator/plan-ladders", EditMethod: "PUT", EditPath: "/partials/operator/plan-ladders/{ladderID}", Target: "#operator-body", Swap: "innerHTML", Fields: []forms.Field{ forms.RecordName(), forms.RecordDescription(), // No Active field on either side: ladder retirement is not wired // up (an inactive ladder still sells and provisions), so the edit // form preserves the stored value verbatim (UpdatePlanLadder) and // no control is offered for it at all. }, Commit: "Create plan ladder", CommitEdit: "Save changes", WayOut: forms.LinkOut("Cancel", "/operator/plan-ladders"), }) func planLadderCreateForm(values forms.Values, errs *forms.Errors) forms.FormView { mode := forms.ModeUnbound if errs.Any() { mode = forms.ModeSubmission } return forms.Render(planLadderForm, forms.Binding{Mode: mode, Side: forms.CreateOnly, Values: values, Errors: errs}) } func planLadderEditForm(ladderID string, values forms.Values, errs *forms.Errors) forms.FormView { mode := forms.ModeRecord if errs.Any() { mode = forms.ModeSubmission } action, err := web.RouteURL(planLadderForm.EditPath, ladderID) if err != nil { action = planLadderForm.Path } return forms.Render(planLadderForm, forms.Binding{Mode: mode, Side: forms.EditOnly, Action: action, Values: values, Errors: errs}) } func planLadderEditValues(v PlanLadderViewModel) forms.Values { values := forms.NewValues() values.Set("name", v.Name) values.Set("description", v.Description) return values } // The tier-add form. const planLadderTierAddFormName = "operator.plan-ladder.tier.add" const planLadderTierAddPanelID = "tierAddPanel" var planLadderTierAddForm = forms.Register(forms.FormSpec{ Name: planLadderTierAddFormName, Kind: forms.KindSubRecord, Family: forms.Dense, Method: "POST", Path: "/partials/operator/plan-ladders/{ladderID}/tiers", // The whole composite re-renders: adding a tier changes the tiers // table, so 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{ planLadderTierProductField(), }, Commit: "Add tier", WayOut: forms.ClosePanel("Cancel", planLadderTierAddPanelID), }) // planLadderTierProductField is the tier's product picker plus the // rule-less warning's live region (design.md M1): choosing a product whose // entitlement set carries no active rule states one sentence under the // control, on the duplicate-name warning's precedent // (operator_product_forms.go's productNameField). The slot id is escaped for // a CSS selector because the form's name carries dots. The sentence warns // and never blocks: the commit stays enabled. func planLadderTierProductField() forms.Field { return forms.Field{ Name: "product_id", Label: "Product", Control: forms.Select, RuntimeOptions: true, Slot: "warning", Attrs: map[string]string{ "hx-get": "/partials/operator/products/rule-check", "hx-trigger": "change", "hx-target": `#form-operator\.plan-ladder\.tier\.add-product_id-warning`, "hx-swap": "innerHTML", }, } } func planLadderTierAddOptions(products []ProductOption) []forms.Option { out := make([]forms.Option, 0, len(products)+1) out = append(out, forms.ChooseOption("a product")) for _, p := range products { out = append(out, forms.Option{Value: p.ProductID, Label: p.Name}) } return out } func planLadderTierAddFormOptions(products []ProductOption) map[string][]forms.Option { return map[string][]forms.Option{"product_id": planLadderTierAddOptions(products)} } func planLadderTierAddFormView(ladderID string, values forms.Values, errs *forms.Errors, products []ProductOption, warning template.HTML) forms.FormView { mode := forms.ModeRecord if errs.Any() { mode = forms.ModeSubmission } action, err := web.RouteURL(planLadderTierAddForm.Path, ladderID) if err != nil { action = planLadderTierAddForm.Path } var slots map[string]template.HTML if warning != "" { slots = map[string]template.HTML{"product_id": warning} } return forms.Render(planLadderTierAddForm, forms.Binding{ Mode: mode, Action: action, Values: values, Errors: errs, Options: planLadderTierAddFormOptions(products), Slots: slots, }) } // The two preview-then-commit forms. Neither carries a way out with a // static destination: Discard re-fetches the specific ladder that // produced the preview, which the declaration's own WayOut is a // placeholder for; the render overrides it with the concrete URL through // Binding.WayOut (finding FA-13's "one alert tone... a filled 'Apply // change' commit... a 'Discard' beside it"). const planLadderTierReorderCommitFormName = "operator.plan-ladder.tier.reorder.commit" const planLadderTierRemoveCommitFormName = "operator.plan-ladder.tier.remove.commit" // planLadderTierNeedsDispositionField is the ShowIf marker both preview // commit forms share: whether the pending change requires a disposition // choice, set from the server-computed preview (PendingTierReorder. // NeedsDisposition, PendingTierRemoval.NeedsDisposition), never from user // choice. func planLadderTierNeedsDispositionField() forms.Field { return forms.Field{Name: "needs_disposition", Label: "Disposition needed", Control: forms.Hidden, Optional: true} } var planLadderTierReorderForm = forms.Register(forms.FormSpec{ Name: planLadderTierReorderCommitFormName, Kind: forms.KindPreview, Family: forms.Stacked, Method: "POST", Path: "/partials/operator/plan-ladders/{ladderID}/tiers/reorder", // The whole composite re-renders: committing a reorder changes ranks // across the tiers table (and, when rank 0 changes, org dispositions), // so 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{ {Name: "order", Label: "Tier order", Control: forms.Hidden, Optional: true}, planLadderTierNeedsDispositionField(), { Name: "bucket2_disposition", Label: "Disposition for organizations on the outgoing default", Control: forms.Radio, Options: []forms.Option{ {Value: "grandfather", Label: "Grandfather: keep their plan as legacy grants"}, {Value: "migrate", Label: "Migrate: move them to the new rank-0 tier"}, }, // No empty-valued option: Parse requires a choice whenever // the field is shown (ShowIf), and skips it entirely when // hidden (design D2's ShowIf; no separate required check // needed). ShowIf: forms.ShowIf{Field: "needs_disposition", Equals: []string{"true"}}, }, }, // The declared URL is a fallback the invariants require of a Discard // way out; every render overrides it with the specific ladder's page // (Binding.WayOut). Commit: "Apply change", WayOut: forms.Discard("Discard", "/operator/plan-ladders"), }) var planLadderTierRemoveForm = forms.Register(forms.FormSpec{ Name: planLadderTierRemoveCommitFormName, Kind: forms.KindPreview, Family: forms.Stacked, Method: "POST", Path: "/partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove", // The whole composite re-renders: committing a removal changes the // tiers table (and org dispositions), so 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{ planLadderTierNeedsDispositionField(), { Name: "default_disposition", Label: "Disposition for organizations on the org type default", Control: forms.Radio, Options: []forms.Option{ {Value: "keep", Label: "Keep: they keep these entitlements off-ladder; the current type default will still apply at their next restoration"}, {Value: "migrate", Label: "Migrate: end this position and apply the current default now"}, }, ShowIf: forms.ShowIf{Field: "needs_disposition", Equals: []string{"true"}}, }, }, Commit: "Apply change", WayOut: forms.Discard("Discard", "/operator/plan-ladders"), }) // boolMarker renders the ShowIf marker's raw value: "true" or "". func boolMarker(on bool) string { if on { return "true" } return "" } // planLadderTierReorderValues binds a pending reorder (or a refused // resubmission of one) to the reorder-commit declaration's fields. func planLadderTierReorderValues(orderIDs []string, needsDisposition bool, disposition string) forms.Values { values := forms.NewValues() values.Set("order", strings.Join(orderIDs, ",")) values.Set("needs_disposition", boolMarker(needsDisposition)) if disposition != "" { values.Set("bucket2_disposition", disposition) } return values } // planLadderTierReorderPreview renders the reorder preview's commit form, // bound to the pending order or to a refused submission of it. discardURL // is the specific ladder page Discard returns to; message is the // narrative rendered into the form's own box, above its fields (design // D10's preview idiom, Binding.Message). func planLadderTierReorderPreview(ladderID, discardURL string, values forms.Values, errs *forms.Errors, message template.HTML) forms.FormView { mode := forms.ModeRecord if errs.Any() { mode = forms.ModeSubmission } action, err := web.RouteURL(planLadderTierReorderForm.Path, ladderID) if err != nil { action = planLadderTierReorderForm.Path } discard := forms.Discard("Discard", discardURL) return forms.Render(planLadderTierReorderForm, forms.Binding{ Mode: mode, Action: action, Values: values, Errors: errs, WayOut: &discard, Message: message, }) } // planLadderTierRemovalValues binds a pending removal (or a refused // resubmission of one) to the removal-commit declaration's fields. func planLadderTierRemovalValues(needsDisposition bool, disposition string) forms.Values { values := forms.NewValues() values.Set("needs_disposition", boolMarker(needsDisposition)) if disposition != "" { values.Set("default_disposition", disposition) } return values } // planLadderTierRemovalPreview renders the removal preview's commit form. func planLadderTierRemovalPreview(ladderID, productID, discardURL string, values forms.Values, errs *forms.Errors, message template.HTML) forms.FormView { mode := forms.ModeRecord if errs.Any() { mode = forms.ModeSubmission } action, err := web.RouteURL(planLadderTierRemoveForm.Path, ladderID, productID) if err != nil { action = planLadderTierRemoveForm.Path } discard := forms.Discard("Discard", discardURL) return forms.Render(planLadderTierRemoveForm, forms.Binding{ Mode: mode, Action: action, Values: values, Errors: errs, WayOut: &discard, Message: message, }) }