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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user