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

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