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

@@ -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.