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

@@ -15,7 +15,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
@@ -120,6 +120,8 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
| `pact.ListRowStates` | Optional controller hook called once per list page; returns the states of the page's records, index-aligned. |
| `pact.RowState` | One state of a list row: `pact.RowStateDeleted`, `pact.RowStateNegative` or `pact.RowStateDisabled`. |
| `pact.FormVirtualFields` | Optional controller list of form fields that are not model columns for the form (a password and its confirmation, for example): never bound, filled or returned; their submitted values reach the Form hooks through the admin framework's context accessor. |
| `pact.FormRules` | Optional controller hook returning the validation rules of an admin save for `create` or `update`; the set replaces the model's `Rules()` for those saves and may name virtual fields. |
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
| `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. |
| `pact.RelationBeforeCreate` | Optional controller hook run in the write transaction before a relation manager creates a related record. |

View File

@@ -486,6 +486,32 @@ type FormAfterDelete interface {
FormAfterDelete(ctx context.Context, model any) error
}
// FormVirtualFields optionally names fields of the controller's form that are
// not model columns for the purpose of the form, such as a password and its
// confirmation or a "send an invitation" checkbox. The admin framework never
// binds such a field to a column, never fills it into the model and never
// returns it in a record response. The values an administrator submits for
// them reach the Form hooks (FormBeforeCreate, FormAfterCreate,
// FormBeforeUpdate, FormAfterUpdate) through cabana.VirtualFieldsFromContext,
// and only for the fields whose `context` allows the operation. Every name
// must be a field of the form's fields.yaml of type password, text, textarea,
// number, checkbox, switch or dropdown; a `type: password` field must be
// listed here.
type FormVirtualFields interface {
FormVirtualFields() []string
}
// FormRules optionally supplies the validation rules of an admin save. op is
// "create" or "update". When a controller implements it, the returned set
// replaces the model's Rules() for saves through the admin form; the form's
// `required` flags are still merged in. Rule strings use the tokens
// lagoon.Validate supports. A rule may name a field listed by
// FormVirtualFields: it is then checked against the submitted value, never
// against a model column of the same name.
type FormRules interface {
FormRules(ctx context.Context, op string) map[string]string
}
// RelationExtendManageQuery optionally narrows relation-manager candidates.
type RelationExtendManageQuery interface {
RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB

View File

@@ -259,3 +259,35 @@ func TestRowStateValues(t *testing.T) {
}
}
}
type formSeams struct{}
func (formSeams) FormVirtualFields() []string { return []string{"password", "password_confirmation"} }
func (formSeams) FormRules(_ context.Context, op string) map[string]string {
if op == "create" {
return map[string]string{"password": "required|confirmed"}
}
return map[string]string{"password": "nullable|confirmed"}
}
func TestFormSeamsDiscoveredByTypeAssertion(t *testing.T) {
var ctl any = formSeams{}
virtual, ok := ctl.(FormVirtualFields)
if !ok || len(virtual.FormVirtualFields()) != 2 {
t.Fatalf("FormVirtualFields = %v ok=%v", virtual, ok)
}
rules, ok := ctl.(FormRules)
if !ok {
t.Fatal("FormRules is not implemented")
}
if got := rules.FormRules(context.Background(), "create")["password"]; got != "required|confirmed" {
t.Fatalf("create rules = %q", got)
}
if got := rules.FormRules(context.Background(), "update")["password"]; got != "nullable|confirmed" {
t.Fatalf("update rules = %q", got)
}
if _, ok := any(neither{}).(FormVirtualFields); ok {
t.Fatal("a plugin without the method implements FormVirtualFields")
}
}