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