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

@@ -221,6 +221,47 @@ A hook or scope that has to read the database during a write should use the tran
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
### Form-only values and rules
A form may collect values that are not columns of the record (see [Form-only fields](forms.md#form-only-fields)). The Form hooks read what the administrator submitted with `cabana.VirtualFieldsFromContext(ctx)`. The map holds only the fields that were sent and that the field's `context` allows for this operation, so a missing key means "not submitted". Values are scalars as decoded from the request: a string, a bool, a `json.Number` or nil. The map is a copy, and the second result is false outside a create or update.
```go src=modules/cabana/example_form_seams_test.go#MembersController.FormBeforeCreate
// 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
}
```
A save validates the record against the model's `Rules()`. Those are often the rules of a public sign-up, which an admin form cannot meet: an update that changes only a name would have to repeat the password. A controller implementing `pact.FormRules` returns the rules of an admin save for `create` or `update`, and that set replaces the model's:
```go src=modules/cabana/example_form_seams_test.go#MembersController.FormRules
// 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
}
```
- The form's `required` flags are still merged in, also for form-only fields.
- A rule on a form-only field is checked against the submitted value, or against nothing when the field was not sent; the model column of the same name is never read. `confirmed` compares with the submitted `<field>_confirmation`.
- Rule strings use the tokens `lagoon.Validate` supports. An unknown token is the opaque 500 on every save, so checks outside that set belong in a hook that returns a `cabana.ValidationError`.
- The model must still have a `Rules` method; relation forms and settings forms keep using the model's rules.
## Refusing a write
A hook or an action stops a write by returning an error. Which error decides what the administrator sees: