feat(12.1-02): password and form-only fields, rules per operation and preset

- pact.FormVirtualFields lists form fields that are not model columns: never
  bound, filled or projected; their values reach the Form hooks through
  cabana.VirtualFieldsFromContext when the field's context allows the operation
- type: password is a masked field that must be listed as virtual
- pact.FormRules supplies the rule set per operation and replaces the model's
  Rules() for admin saves; a rule on a virtual field sees the submitted value
- preset on a text field follows another text field on the create form
- SPA: PasswordField, preset handling in FormView, empty password left out of
  an update
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:35:08 +02:00
parent a65c670574
commit a1c6bb1ce6
42 changed files with 1284 additions and 49 deletions

View File

@@ -2,6 +2,8 @@ package cabana_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"net/http"
@@ -38,13 +40,35 @@ type rosterPerson struct {
Active bool `gorm:"column:active"`
Banned bool `gorm:"column:banned"`
// JoinedIP is shown on the preview screen only (context: preview).
JoinedIP *string `gorm:"column:joined_ip"`
JoinedIP *string `gorm:"column:joined_ip"`
// Password is a stored hash. The form's password field is virtual: the
// controller's hooks derive this column from the submitted value.
Password string `gorm:"column:password" json:"-"`
Slug string `gorm:"column:slug"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (rosterPerson) TableName() string { return "roster_people" }
func (rosterPerson) Fillable() []string { return []string{"name", "email"} }
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
func (rosterPerson) TableName() string { return "roster_people" }
func (rosterPerson) Fillable() []string { return []string{"name", "email", "slug"} }
// Rules are the model's own (sign-up) rules: every save needs a confirmed
// password. The admin form replaces them through the controller's FormRules.
func (rosterPerson) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// rosterHash is the fixture's stand-in for a password hash.
func rosterHash(plain string) string {
sum := sha256.Sum256([]byte(plain))
return "sha256:" + hex.EncodeToString(sum[:])
}
// rosterVirtual is what one Form hook read from VirtualFieldsFromContext.
type rosterVirtual struct {
Hook string
Values map[string]any
Found bool
}
// rosterSpy records what each registered action's Run receives.
type rosterSpy struct {
@@ -53,6 +77,31 @@ type rosterSpy struct {
record []pact.AdminRecordActionInput
// states counts ListRowStates calls and keeps the size of each page.
states []int
// virtual keeps what each Form hook read from the context.
virtual []rosterVirtual
}
func (s *rosterSpy) recordVirtual(hook string, ctx context.Context) map[string]any {
values, found := cabana.VirtualFieldsFromContext(ctx)
if s == nil {
return values
}
s.mu.Lock()
defer s.mu.Unlock()
kept := make(map[string]any, len(values))
for name, value := range values {
kept[name] = value
}
s.virtual = append(s.virtual, rosterVirtual{Hook: hook, Values: kept, Found: found})
return values
}
func (s *rosterSpy) takeVirtual() []rosterVirtual {
s.mu.Lock()
defer s.mu.Unlock()
out := s.virtual
s.virtual = nil
return out
}
func (s *rosterSpy) recordStates(n int) {
@@ -216,9 +265,49 @@ const rosterLocked = "Locked"
// copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
// the field, and fails with a plain error for the name Boom.
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
// FormVirtualFields lists the form fields that are not columns of the form:
// the password pair and the create-only notify checkbox.
func (rosterController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
// FormRules are the admin form's rules: a create needs a confirmed password,
// an update takes one only when it is submitted.
func (rosterController) FormRules(_ context.Context, op string) map[string]string {
if op == "create" {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
return map[string]string{"name": "required", "password": "nullable|between:8,255|confirmed"}
}
// storePassword derives the stored hash from a submitted password.
func storePassword(person *rosterPerson, values map[string]any) {
if plain, ok := values["password"].(string); ok && plain != "" {
person.Password = rosterHash(plain)
}
}
// FormBeforeCreate stamps the tenant and stores the hash of the submitted
// password. It also drops notify from its own copy of the virtual values: the
// after hook must still see it.
func (c rosterController) FormBeforeCreate(ctx context.Context, model any) error {
values := c.spy.recordVirtual("before-create", ctx)
model.(*rosterPerson).Tenant = "acme"
storePassword(model.(*rosterPerson), values)
delete(values, "notify")
return nil
}
func (c rosterController) FormAfterCreate(ctx context.Context, _ any) error {
c.spy.recordVirtual("after-create", ctx)
return nil
}
// FormBeforeUpdate stores a submitted password, refuses the reserved name
// with a ForbiddenError naming the field, and fails with a plain error for
// the name Boom.
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
switch model.(*rosterPerson).Name {
case "Reserved":
return &cabana.ForbiddenError{