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

@@ -21,6 +21,8 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
@@ -206,6 +208,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |

View File

@@ -262,7 +262,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @Summary Admin form schema
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.
// @Tags admin
// @Produce json
// @Security BackendBearer

View File

@@ -102,6 +102,9 @@ type CompiledController struct {
// dates are the form's `type: datepicker` fields after the Go type
// check, keyed by field name.
dates map[string]*compiledDate
// virtual is the set of form field names the controller lists through
// pact.FormVirtualFields: never bound, filled or projected.
virtual map[string]bool
}
// Registry is the immutable controller map keyed by controller ID.

View File

@@ -176,7 +176,9 @@ func ProjectWritableFields(cc *CompiledController, body map[string]any) map[stri
}
// BindWritableFields records schema field names onto model column fill keys.
// Protected columns are omitted. A scalar field with no column fails activation.
// Protected columns and the fields the controller lists through
// pact.FormVirtualFields are omitted. Any other scalar field with no column
// fails activation.
func BindWritableFields(cc *CompiledController) error {
if cc == nil || cc.Form == nil {
return nil
@@ -190,9 +192,13 @@ func BindWritableFields(cc *CompiledController) error {
return nil
}
cols := modelColumns(src.NewRecord())
// A virtual field (pact.FormVirtualFields) is not a column of the form:
// it gets no column check and no binding, so Fill never receives it and
// no response returns it.
cc.virtual = virtualFieldSet(cc.Controller)
bindings := make([]WritableField, 0, len(cc.Form.Fields))
for _, field := range cc.Form.Fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
continue
}
if _, known := cols[field.Name]; !known {
@@ -568,9 +574,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return RecordResult{}, err
}
// Virtual field values never reach Fill: they are handed to the Form
// hooks on the context and to the rules.
virtual, err := liftVirtualValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
var result RecordResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
ctx = withVirtualFields(withTx(ctx, tx), virtual)
target, err := newWritableModel(cc)
if err != nil {
return err
@@ -599,8 +611,8 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return &CapabilityError{ControllerID: controllerID(cc)}
}
}
rules := mergedRules(cc, target, op)
msgs, err := lagoon.Validate(ctx, tx, target, rules, valuesForRules(target, rules), nil)
rules := mergedRules(ctx, cc, target, op)
msgs, err := lagoon.Validate(ctx, tx, target, rules, virtualValuesForRules(cc, target, rules, virtual), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
@@ -991,12 +1003,23 @@ func fillAllowed(cc *CompiledController, model any, op string) []string {
return out
}
// mergedRules combines the model's rules with the form's `required` flags. A
// field whose `context` hides it on op cannot be supplied there, so its form
// level `required` does not apply to that operation.
func mergedRules(cc *CompiledController, model any, op string) map[string]string {
// mergedRules combines the base rules with the form's `required` flags. The
// base is the model's Rules(), or the set a controller implementing
// pact.FormRules returns for op (create or update), which replaces the model's
// rules for admin saves. A field whose `context` hides it on op cannot be
// supplied there, so its form level `required` does not apply to that
// operation.
func mergedRules(ctx context.Context, cc *CompiledController, model any, op string) map[string]string {
out := map[string]string{}
if rules, ok := model.(hasRules); ok && rules != nil {
var own pact.FormRules
if cc != nil && cc.Controller != nil && (op == "create" || op == "update") {
own, _ = cc.Controller.(pact.FormRules)
}
if own != nil {
for key, rule := range own.FormRules(ctx, op) {
out[key] = rule
}
} else if rules, ok := model.(hasRules); ok && rules != nil {
for key, rule := range rules.Rules() {
out[key] = rule
}
@@ -1006,14 +1029,77 @@ func mergedRules(cc *CompiledController, model any, op string) map[string]string
}
for _, field := range cc.Form.Fields {
// Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill.
if field.Required && scalarFormField(field.Type) && contextAllows(cc, field.Name, op) {
// schema for the client, but it cannot be checked by Fill. A virtual
// field is checked against its submitted value.
if field.Required && (scalarFormField(field.Type) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
out[field.Name] = mergeRequired(out[field.Name])
}
}
return out
}
// virtualFieldSet is the set of names a controller lists through
// pact.FormVirtualFields; nil when it lists none.
func virtualFieldSet(ctl pact.AdminController) map[string]bool {
src, ok := ctl.(pact.FormVirtualFields)
if !ok || src == nil {
return nil
}
names := src.FormVirtualFields()
if len(names) == 0 {
return nil
}
out := make(map[string]bool, len(names))
for _, name := range names {
out[name] = true
}
return out
}
// liftVirtualValues collects the submitted values of the form's virtual
// fields (pact.FormVirtualFields) for op: only fields present in the body
// whose context allows the operation. A nested value is validation_failed on
// the field. The result is never nil.
func liftVirtualValues(cc *CompiledController, body map[string]any, op string) (map[string]any, error) {
out := map[string]any{}
if cc == nil || cc.Form == nil || len(cc.virtual) == 0 {
return out, nil
}
for _, field := range cc.Form.Fields {
if !cc.virtual[field.Name] || !contextAllows(cc, field.Name, op) {
continue
}
value, present := body[field.Name]
if !present {
continue
}
if nestedValue(value) {
return nil, &ValidationError{Details: fillTypeDetails(field.Name)}
}
out[field.Name] = value
}
return out, nil
}
// virtualValuesForRules is valuesForRules for a controller save: a rule on a
// virtual field sees the submitted value, or nothing when the field was not
// submitted, and never the model column of the same name (a stored password
// hash, for example). Every submitted virtual value is present, so `confirmed`
// and `different` can read a field no rule names.
func virtualValuesForRules(cc *CompiledController, model any, rules map[string]string, virtual map[string]any) map[string]any {
out := valuesForRules(model, rules)
if cc == nil || len(cc.virtual) == 0 {
return out
}
for name := range cc.virtual {
delete(out, name)
}
for name, value := range virtual {
out[name] = value
}
return out
}
func mergeRequired(rule string) string {
if strings.TrimSpace(rule) == "" {
return "required"

View File

@@ -0,0 +1,115 @@
package cabana_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Member is the model behind the acme.roster members controller. Password
// holds a hash; the form never reads or writes the column itself.
type Member struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Slug string `gorm:"column:slug"`
Password string `gorm:"column:password" json:"-"`
}
func (Member) TableName() string { return "acme_roster_members" }
// Fillable lists the columns the admin form may write.
func (Member) Fillable() []string { return []string{"name", "slug"} }
// Rules are the model's own rules, used wherever the controller sets none.
func (Member) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// MembersController is an admin controller whose form has fields that are
// not columns and rules of its own.
type MembersController struct{}
var (
_ pact.AdminController = MembersController{}
_ pact.AdminRecordSource = MembersController{}
_ pact.FormVirtualFields = MembersController{}
_ pact.FormRules = MembersController{}
_ pact.FormBeforeCreate = MembersController{}
_ pact.FormBeforeUpdate = MembersController{}
)
func (MembersController) ID() string { return "acme.roster.members" }
func (MembersController) ModelName() string { return "Member" }
func (MembersController) ConfigDir() string { return "controllers/members" }
func (MembersController) NewRecord() any { return &Member{} }
// FormVirtualFields names the fields of fields.yaml that are not columns of
// the form. cabana never fills or returns them.
func (MembersController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
// FormRules replaces the model's rules for admin saves: a create needs a
// password, an update takes one only when the administrator types it.
func (MembersController) FormRules(_ context.Context, op string) map[string]string {
rules := map[string]string{"name": "required"}
if op == "create" {
rules["password"] = "required|between:8,255|confirmed"
} else {
rules["password"] = "nullable|between:8,255|confirmed"
}
return rules
}
// FormBeforeCreate reads the submitted virtual values, which have passed the
// rules by now, and stores what the model needs.
func (MembersController) FormBeforeCreate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
member := model.(*Member)
if plain, ok := values["password"].(string); ok && plain != "" {
member.Password = hashPassword(plain)
}
if notify, _ := values["notify"].(bool); notify {
// Queue the welcome message here.
}
return nil
}
// FormBeforeUpdate changes the password only when one was submitted.
func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
if plain, ok := values["password"].(string); ok && plain != "" {
model.(*Member).Password = hashPassword(plain)
}
return nil
}
// hashPassword stands in for the application's password hasher.
func hashPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
// Example_formSeams shows what the controller declares. Outside a save there
// are no submitted values, so the hook stores nothing.
func Example_formSeams() {
ctl := MembersController{}
ctx := context.Background()
fmt.Println(ctl.FormVirtualFields())
fmt.Println("create:", ctl.FormRules(ctx, "create")["password"])
fmt.Println("update:", ctl.FormRules(ctx, "update")["password"])
member := &Member{Name: "Ada"}
_, inSave := cabana.VirtualFieldsFromContext(ctx)
err := ctl.FormBeforeCreate(ctx, member)
fmt.Println(inSave, err, member.Password == "")
// Output:
// [password password_confirmation notify]
// create: required|between:8,255|confirmed
// update: nullable|between:8,255|confirmed
// false <nil> true
}

View File

@@ -113,6 +113,12 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
for _, field := range cc.Form.Fields {
fields[field.Name] = field
}
if err := checkVirtualFields(cc, fields); err != nil {
return bootErr(pluginID, id, file, err)
}
if err := checkPresets(cc.Form.Fields); err != nil {
return bootErr(pluginID, id, file, err)
}
writable := map[string]bool{}
for _, field := range cc.Writable {
writable[field.Name] = true
@@ -147,6 +153,44 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
return nil
}
// virtualFieldTypes are the field types a controller may list through
// pact.FormVirtualFields: the value is one scalar.
var virtualFieldTypes = map[string]bool{
"password": true, "text": true, "textarea": true, "number": true,
"checkbox": true, "switch": true, "dropdown": true,
}
// checkVirtualFields checks the controller's pact.FormVirtualFields list
// against the form: every `type: password` field must be listed (its value is
// never a model column), and every listed name must be a field of the form
// with a scalar type.
func checkVirtualFields(cc *CompiledController, fields map[string]FormField) error {
for _, field := range cc.Form.Fields {
if field.Type == "password" && !cc.virtual[field.Name] {
return fmt.Errorf("field %s: type password needs the controller to list it in FormVirtualFields", field.Name)
}
}
src, ok := cc.Controller.(pact.FormVirtualFields)
if !ok || src == nil {
return nil
}
seen := map[string]bool{}
for _, name := range src.FormVirtualFields() {
if seen[name] {
return fmt.Errorf("FormVirtualFields lists field %s twice", name)
}
seen[name] = true
field, exists := fields[name]
if !exists {
return fmt.Errorf("FormVirtualFields: field %s is not a field of this form", name)
}
if !virtualFieldTypes[field.Type] {
return fmt.Errorf("FormVirtualFields: field %s has type %s (want password, text, textarea, number, checkbox, switch or dropdown)", name, field.Type)
}
}
return nil
}
// compilePartials reads and parses every partial the controller declares:
// config_list.yaml headerPartial, config_form.yaml preview.headerPartial and
// each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm.

View File

@@ -25,6 +25,7 @@ var (
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -42,6 +43,7 @@ var (
"thumbOptions": {}, "useCaption": {}, "prompt": {},
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
"preset": {},
}
// widgetKeys are valid only on `type: widget` (D-06).
widgetKeys = []string{"widget", "action", "fill"}
@@ -244,6 +246,10 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
field.Prompt = translateKey(ctx, tr, src.Prompt)
field.Fill = append([]string(nil), src.Fill...)
if src.Preset != nil {
preset := *src.Preset
field.Preset = &preset
}
field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...)
@@ -548,6 +554,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err := compileDatepickerKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compilePresetKey(typ, values, &field); err != nil {
return FormField{}, err
}
if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node)
if err != nil {
@@ -624,6 +633,81 @@ func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField)
return nil
}
// compilePresetKey decodes `preset` (D-27): the name of the field this text
// field follows while the administrator has not edited it, on create only. It
// is a string (the source field; type slug) or a mapping with the keys field
// and type, where type is slug or exact. The source is checked against the
// form in checkPresets.
func compilePresetKey(typ string, values map[string]ast.Node, field *FormField) error {
node, ok := values["preset"]
if !ok {
return nil
}
if typ != "text" {
return fmt.Errorf("preset is only valid on type: text")
}
preset := FieldPreset{Type: "slug"}
switch n := node.(type) {
case *ast.StringNode:
preset.Field = n.Value
case *ast.MappingNode:
for _, entry := range n.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("preset: %w", err)
}
value, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("preset: %s: %w", key, err)
}
switch key {
case "field":
preset.Field = value
case "type":
preset.Type = value
default:
return fmt.Errorf("preset: unknown field %s", key)
}
}
default:
return fmt.Errorf("preset must be a field name or a mapping with field and type")
}
if !identifier(preset.Field) {
return fmt.Errorf("preset field %q is not an identifier", preset.Field)
}
if preset.Type != "slug" && preset.Type != "exact" {
return fmt.Errorf("preset type %s is not supported (want slug or exact)", preset.Type)
}
field.Preset = &preset
return nil
}
// checkPresets checks every preset of a form against its fields: the source
// must be another text field of the same form.
func checkPresets(fields []FormField) error {
types := make(map[string]string, len(fields))
for _, field := range fields {
types[field.Name] = field.Type
}
for _, field := range fields {
if field.Preset == nil {
continue
}
source := field.Preset.Field
if source == field.Name {
return fmt.Errorf("field %s: preset names the field itself", field.Name)
}
typ, ok := types[source]
if !ok {
return fmt.Errorf("field %s: preset field %s is not a field of this form", field.Name, source)
}
if typ != "text" {
return fmt.Errorf("field %s: preset field %s must be a text field", field.Name, source)
}
}
return nil
}
// partialPathHint is the D-11 path rule shared by every partial path error.
const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported"

View File

@@ -304,7 +304,7 @@ func TestRecordActionSmoke(t *testing.T) {
})
t.Run("create and update responses carry no actions", func(t *testing.T) {
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh"}`, "bearer")
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
if strings.Contains(rec.Body.String(), `"actions"`) {
t.Fatalf("create response: %s", rec.Body.String())
}

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{

View File

@@ -1,9 +1,12 @@
package cabana_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@@ -93,7 +96,7 @@ func TestPreviewSmoke(t *testing.T) {
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
}
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer")
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
created := rosterRecord(t, rec.Body.Bytes())
id, _ := created.Data["id"].(float64)
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
@@ -136,3 +139,250 @@ func TestPreviewSmoke(t *testing.T) {
}
})
}
// rosterFields is the fixture's fields.yaml with old replaced by new (boot
// tests); an empty old appends new.
func rosterFields(t *testing.T, old, new string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(rosterDir, rosterFieldsFile))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if old == "" {
return text + new
}
if !strings.Contains(text, old) {
t.Fatalf("fields.yaml does not contain %q", old)
}
return strings.Replace(text, old, new, 1)
}
const rosterFieldsFile = "models/person/fields.yaml"
// rosterErrorDetail asserts a 4xx body's code and one field message.
func rosterErrorDetail(t *testing.T, raw []byte, code, field, message string) {
t.Helper()
var body cabana.ErrorEnvelope
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("error body %s: %v", raw, err)
}
if body.Error.Code != code {
t.Fatalf("code = %q, want %s; body %s", body.Error.Code, code, raw)
}
list, _ := body.Error.Details[field].([]any)
for _, item := range list {
if item == message {
return
}
}
t.Fatalf("details[%s] = %v, want %q; body %s", field, body.Error.Details[field], message, raw)
}
// TestPasswordFieldSmoke drives `type: password` through the assembled router
// on PostgreSQL (D-27 G1; T-12.1-10): the value reaches the controller's hook,
// which stores a hash, and no record response on any route carries the key or
// the plain text.
func TestPasswordFieldSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
const plain, next = "s3cret-plain-text", "another-plain-9"
leaks := func(t *testing.T, route, body string) {
t.Helper()
for _, part := range []string{"password", plain, next, "sha256:"} {
if strings.Contains(body, part) {
t.Fatalf("%s response carries %q: %s", route, part, body)
}
}
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople,
fmt.Sprintf(`{"name":"Pat","password":%q,"password_confirmation":%q}`, plain, plain), "bearer")
leaks(t, "create", rec.Body.String())
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
id := uint(idFloat)
record := fmt.Sprintf("%s/%d", rosterPeople, id)
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(plain) || stored.Password == plain {
t.Fatalf("stored password = %q", stored.Password)
}
rec = env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
leaks(t, "show", rec.Body.String())
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
leaks(t, "list", rec.Body.String())
rec = env.expect(t, http.StatusOK, http.MethodPut, record,
fmt.Sprintf(`{"name":"Pat B","password":%q,"password_confirmation":%q}`, next, next), "bearer")
leaks(t, "update", rec.Body.String())
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat B" {
t.Fatalf("after update: %+v", stored)
}
t.Run("an update without a password keeps the stored one", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Pat C"}`, "bearer")
leaks(t, "update", rec.Body.String())
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
t.Fatalf("stored = %+v", stored)
}
})
t.Run("a mismatch, a lone confirmation and a short password are 422 on password", func(t *testing.T) {
const mismatch = "The password confirmation does not match."
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":"Pat D","password":"long-enough-1","password_confirmation":"long-enough-2"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password_confirmation":"long-enough-2"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":"short","password_confirmation":"short"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password must be between 8 and 255 characters.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Mis","password":"long-enough-1","password_confirmation":"other-enough-1"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
// Nothing of the refused saves was written.
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
t.Fatalf("a refused save wrote: %+v", stored)
}
})
t.Run("the schema serves the field without a value", func(t *testing.T) {
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"password","type":"password","label":"Password","span":"left","context":["create","update"]`) {
t.Fatalf("password field is not in the schema: %s", raw)
}
})
}
// TestVirtualFieldsSmoke drives pact.FormVirtualFields (D-27 G2; T-12.1-09):
// submitted values reach the Form hooks through VirtualFieldsFromContext only
// when the field's context allows the operation, and are never filled or
// returned.
func TestVirtualFieldsSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
if _, ok := cabana.VirtualFieldsFromContext(context.Background()); ok {
t.Fatal("virtual fields reported outside a save")
}
const body = `{"name":"Vic","notify":true,"password":"long-enough-1","password_confirmation":"long-enough-1"}`
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, body, "bearer")
for _, name := range []string{"notify", "password", "password_confirmation"} {
if strings.Contains(rec.Body.String(), name) {
t.Fatalf("create response carries %s: %s", name, rec.Body.String())
}
}
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
record := fmt.Sprintf("%s/%d", rosterPeople, uint(idFloat))
seen := env.spy.takeVirtual()
if len(seen) != 2 || seen[0].Hook != "before-create" || seen[1].Hook != "after-create" {
t.Fatalf("hooks = %+v", seen)
}
for _, hook := range seen {
// The before hook deleted notify from its copy; the after hook
// still sees it.
if !hook.Found || hook.Values["notify"] != true || hook.Values["password"] != "long-enough-1" || hook.Values["password_confirmation"] != "long-enough-1" || len(hook.Values) != 3 {
t.Fatalf("%s saw %+v found=%v", hook.Hook, hook.Values, hook.Found)
}
}
t.Run("a field whose context hides it on update never reaches the hook", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Vic B","notify":true}`, "bearer")
if strings.Contains(rec.Body.String(), "notify") {
t.Fatalf("update response: %s", rec.Body.String())
}
seen := env.spy.takeVirtual()
if len(seen) != 1 || seen[0].Hook != "before-update" || !seen[0].Found || len(seen[0].Values) != 0 {
t.Fatalf("update hook saw %+v", seen)
}
})
t.Run("a nested value is 422 on the field and nothing is written", func(t *testing.T) {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Nest","notify":{"on":true},"password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field has an invalid value.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":["a","b"]}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field has an invalid value.")
var count int64
if err := gdb.Model(&rosterPerson{}).Where("name = ?", "Nest").Count(&count).Error; err != nil || count != 0 {
t.Fatalf("nested create wrote %d rows err=%v", count, err)
}
if seen := env.spy.takeVirtual(); len(seen) != 0 {
t.Fatalf("a refused save reached a hook: %+v", seen)
}
})
t.Run("a virtual name is never a fill key", func(t *testing.T) {
// The model has a password column; the submitted text must reach it
// only through the hook (as a hash), never through Fill.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"password":"plain-through-fill","password_confirmation":"plain-through-fill"}`, "bearer")
var stored rosterPerson
if err := gdb.Unscoped().First(&stored, uint(idFloat)).Error; err != nil {
t.Fatal(err)
}
if stored.Password != rosterHash("plain-through-fill") {
t.Fatalf("stored password = %q", stored.Password)
}
})
}
// TestFormRulesSmoke drives pact.FormRules (D-28 G5): the controller's rule
// set per operation replaces the model's Rules() for admin saves.
func TestFormRulesSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Rae", Active: true, Password: rosterHash("stored-before")})
record := fmt.Sprintf("%s/%d", rosterPeople, id)
t.Run("an update of name alone passes although the model demands a confirmed password", func(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Rae B"}`, "bearer")
if stored := rosterLoad(t, gdb, id); stored.Name != "Rae B" || stored.Password != rosterHash("stored-before") {
t.Fatalf("stored = %+v", stored)
}
})
t.Run("the create rules need a password and the update rules a name", func(t *testing.T) {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"No password"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":""}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
})
t.Run("boot rules", func(t *testing.T) {
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " secret:\n type: password\n")},
"field secret: type password needs the controller to list it in FormVirtualFields", "acme.roster.people", rosterFieldsFile)
// A form-only text field the controller does not list is still a
// column error.
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " nickname:\n type: text\n")},
"field nickname is not a model column")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " notify:\n label: acme.roster::lang.people.notify\n type: checkbox\n default: true\n context: create\n", "")},
"FormVirtualFields: field notify is not a field of this form", rosterFieldsFile)
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: partial\n path: status\n")},
"FormVirtualFields: field notify has type partial")
})
}
// TestPresetSchema checks the fields.yaml preset key (D-27 G7): the schema
// carries it and its boot rules hold.
func TestPresetSchema(t *testing.T) {
env, _ := newRosterEnv(t)
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"slug","type":"text","label":"Slug","preset":{"field":"name","type":"slug"}`) {
t.Fatalf("preset is not in the schema: %s", raw)
}
for _, tc := range []struct {
name, old, new string
want string
}{
{"mapping with exact", " preset: name\n", " preset:\n field: name\n type: exact\n", ""},
{"unsupported type", " preset: name\n", " preset:\n field: name\n type: camel\n", "preset type camel is not supported (want slug or exact)"},
{"unknown key", " preset: name\n", " preset:\n field: name\n prefix: x\n", "preset: unknown field prefix"},
{"not a text target", " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n", "preset is only valid on type: text"},
{"unknown source", " preset: name\n", " preset: title\n", "field slug: preset field title is not a field of this form"},
{"source is not text", " preset: name\n", " preset: notify\n", "field slug: preset field notify must be a text field"},
{"itself", " preset: name\n", " preset: slug\n", "field slug: preset names the field itself"},
} {
t.Run(tc.name, func(t *testing.T) {
replace := map[string]string{rosterFieldsFile: rosterFields(t, tc.old, tc.new)}
if tc.want == "" {
if err := rosterBoot(t, rosterTree(t, replace)); err != nil {
t.Fatalf("did not boot: %v", err)
}
return
}
rosterBootFails(t, replace, tc.want, rosterFieldsFile)
})
}
}

View File

@@ -1131,7 +1131,7 @@ func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *Compile
return &CapabilityError{ControllerID: controllerID(form)}
}
rules := map[string]string{}
for key, rule := range mergedRules(form, pivot, "") {
for key, rule := range mergedRules(ctx, form, pivot, "") {
if slices.Contains(allowed, key) {
rules[key] = rule
}

View File

@@ -113,7 +113,7 @@ func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *Compi
return &CapabilityError{ControllerID: controllerID(form)}
}
}
rules := mergedRules(form, model, op)
rules := mergedRules(ctx, form, model, op)
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(form)}

View File

@@ -19,6 +19,8 @@ import (
// plugin extensions need a parent record scope a child modal does not have.
var relationFormRefusedTypes = map[string]bool{
"relation": true, "relation-manager": true, "widget": true, "partial": true,
// A password is a virtual field, and only an admin controller lists those.
"password": true,
}
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].
@@ -152,6 +154,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
if relationFormRefusedTypes[field.Type] {
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose))
}
if field.Preset != nil {
return nil, fail(fmt.Errorf("field %s: preset is not supported in a relation form (%s)", field.Name, purpose))
}
if cr.hasMany() && field.Name == cr.Contract.ForeignKey {
return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name))
}

View File

@@ -301,10 +301,23 @@ type FormField struct {
// managed before the record is first saved (RelationSchema.Deferrable):
// the SPA shows it on the create screen.
Deferrable bool `json:"deferrable,omitempty"`
// Preset makes a text field follow another field of the form while the
// administrator has not edited it, on create only (fields.yaml preset).
Preset *FieldPreset `json:"preset,omitempty"`
optionsMethod string
}
// FieldPreset is a text field's `preset`: Field names the text field of the
// same form whose value it follows, and Type is how the value is taken over,
// slug (lower-case ASCII with hyphens) or exact (the same text). The admin SPA
// applies it on the create form until the administrator edits the field; the
// server does not fill the field.
type FieldPreset struct {
Field string `json:"field"`
Type string `json:"type"`
}
// ThumbOptions is a fileupload field's thumbOptions mapping. Mode is one of
// auto, exact, crop or fit.
type ThumbOptions struct {

View File

@@ -63,9 +63,12 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
}
for _, field := range fields {
// A settings screen has no admin controller to own actions or view models.
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" {
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" {
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
}
if field.Preset != nil {
return nil, fmt.Errorf("cabana: setting %s field %s: preset is not supported on a settings form", item.Code, field.Name)
}
}
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
columns := modelColumns(model)

View File

@@ -24,3 +24,7 @@ people:
deleted_text: An archived person is hidden from the directory.
inactive_title: This person is not active
inactive_text: Activate the person to let them sign in.
slug: Slug
password: Password
password_confirmation: Repeat the password
notify: Send a welcome message

View File

@@ -24,3 +24,7 @@ people:
deleted_text: Zarchiwizowana osoba jest ukryta w katalogu.
inactive_title: Ta osoba jest nieaktywna
inactive_text: Aktywuj osobę, aby mogła się zalogować.
slug: Slug
password: Hasło
password_confirmation: Powtórz hasło
notify: Wyślij wiadomość powitalną

View File

@@ -7,6 +7,25 @@ fields:
label: acme.roster::lang.people.email
type: text
span: right
slug:
label: acme.roster::lang.people.slug
type: text
preset: name
password:
label: acme.roster::lang.people.password
type: password
span: left
context: [create, update]
password_confirmation:
label: acme.roster::lang.people.password_confirmation
type: password
span: right
context: [create, update]
notify:
label: acme.roster::lang.people.notify
type: checkbox
default: true
context: create
joined_ip:
label: acme.roster::lang.people.joined_ip
type: text

View File

@@ -31,3 +31,37 @@ func TxFromContext(ctx context.Context) (*gorm.DB, bool) {
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
return tx, ok && tx != nil
}
type virtualFieldsKey struct{}
// withVirtualFields returns ctx carrying the virtual field values of a save.
func withVirtualFields(ctx context.Context, values map[string]any) context.Context {
if values == nil {
values = map[string]any{}
}
return context.WithValue(ctx, virtualFieldsKey{}, values)
}
// VirtualFieldsFromContext returns the values an administrator submitted for
// the form's virtual fields (pact.FormVirtualFields), keyed by field name, for
// the context handed to a Form hook (pact.FormBeforeCreate,
// pact.FormAfterCreate, pact.FormBeforeUpdate, pact.FormAfterUpdate). Only
// fields that were present in the request body and whose `context` allows the
// operation are in the map, so a missing key means "not submitted". Values
// are scalars as decoded from the JSON body: a string, a bool, a json.Number
// or nil. The map is a copy. The second result is false outside a create or
// update save.
func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
if ctx == nil {
return nil, false
}
values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any)
if !ok {
return nil, false
}
out := make(map[string]any, len(values))
for name, value := range values {
out[name] = value
}
return out, true
}