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:
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-B2epb8_Z.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-B2epb8_Z.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-DEO3TzjB.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-DEO3TzjB.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-DEJgWNHv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-57SuA8gQ.css">
|
||||
<script type="module" crossorigin src="./assets/index-DEO3TzjB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B2epb8_Z.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -21,6 +21,8 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
|
||||
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
|
||||
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
||||
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
||||
@@ -206,6 +208,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
|
||||
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
||||
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
|
||||
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
|
||||
|
||||
@@ -262,7 +262,7 @@ func AdminListSchema() {}
|
||||
// AdminFormSchema documents the form schema route.
|
||||
//
|
||||
// @Summary Admin form schema
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
|
||||
@@ -102,6 +102,9 @@ type CompiledController struct {
|
||||
// dates are the form's `type: datepicker` fields after the Go type
|
||||
// check, keyed by field name.
|
||||
dates map[string]*compiledDate
|
||||
// virtual is the set of form field names the controller lists through
|
||||
// pact.FormVirtualFields: never bound, filled or projected.
|
||||
virtual map[string]bool
|
||||
}
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
|
||||
@@ -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"
|
||||
|
||||
115
modules/cabana/example_form_seams_test.go
Normal file
115
modules/cabana/example_form_seams_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Member is the model behind the acme.roster members controller. Password
|
||||
// holds a hash; the form never reads or writes the column itself.
|
||||
type Member struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
}
|
||||
|
||||
func (Member) TableName() string { return "acme_roster_members" }
|
||||
|
||||
// Fillable lists the columns the admin form may write.
|
||||
func (Member) Fillable() []string { return []string{"name", "slug"} }
|
||||
|
||||
// Rules are the model's own rules, used wherever the controller sets none.
|
||||
func (Member) Rules() map[string]string {
|
||||
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
||||
}
|
||||
|
||||
// MembersController is an admin controller whose form has fields that are
|
||||
// not columns and rules of its own.
|
||||
type MembersController struct{}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = MembersController{}
|
||||
_ pact.AdminRecordSource = MembersController{}
|
||||
_ pact.FormVirtualFields = MembersController{}
|
||||
_ pact.FormRules = MembersController{}
|
||||
_ pact.FormBeforeCreate = MembersController{}
|
||||
_ pact.FormBeforeUpdate = MembersController{}
|
||||
)
|
||||
|
||||
func (MembersController) ID() string { return "acme.roster.members" }
|
||||
func (MembersController) ModelName() string { return "Member" }
|
||||
func (MembersController) ConfigDir() string { return "controllers/members" }
|
||||
func (MembersController) NewRecord() any { return &Member{} }
|
||||
|
||||
// FormVirtualFields names the fields of fields.yaml that are not columns of
|
||||
// the form. cabana never fills or returns them.
|
||||
func (MembersController) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", "notify"}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// FormBeforeUpdate changes the password only when one was submitted.
|
||||
func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||
if plain, ok := values["password"].(string); ok && plain != "" {
|
||||
model.(*Member).Password = hashPassword(plain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword stands in for the application's password hasher.
|
||||
func hashPassword(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Example_formSeams shows what the controller declares. Outside a save there
|
||||
// are no submitted values, so the hook stores nothing.
|
||||
func Example_formSeams() {
|
||||
ctl := MembersController{}
|
||||
ctx := context.Background()
|
||||
fmt.Println(ctl.FormVirtualFields())
|
||||
fmt.Println("create:", ctl.FormRules(ctx, "create")["password"])
|
||||
fmt.Println("update:", ctl.FormRules(ctx, "update")["password"])
|
||||
|
||||
member := &Member{Name: "Ada"}
|
||||
_, inSave := cabana.VirtualFieldsFromContext(ctx)
|
||||
err := ctl.FormBeforeCreate(ctx, member)
|
||||
fmt.Println(inSave, err, member.Password == "")
|
||||
// Output:
|
||||
// [password password_confirmation notify]
|
||||
// create: required|between:8,255|confirmed
|
||||
// update: nullable|between:8,255|confirmed
|
||||
// false <nil> true
|
||||
}
|
||||
@@ -113,6 +113,12 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
||||
for _, field := range cc.Form.Fields {
|
||||
fields[field.Name] = field
|
||||
}
|
||||
if err := checkVirtualFields(cc, fields); err != nil {
|
||||
return bootErr(pluginID, id, file, err)
|
||||
}
|
||||
if err := checkPresets(cc.Form.Fields); err != nil {
|
||||
return bootErr(pluginID, id, file, err)
|
||||
}
|
||||
writable := map[string]bool{}
|
||||
for _, field := range cc.Writable {
|
||||
writable[field.Name] = true
|
||||
@@ -147,6 +153,44 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// virtualFieldTypes are the field types a controller may list through
|
||||
// pact.FormVirtualFields: the value is one scalar.
|
||||
var virtualFieldTypes = map[string]bool{
|
||||
"password": true, "text": true, "textarea": true, "number": true,
|
||||
"checkbox": true, "switch": true, "dropdown": true,
|
||||
}
|
||||
|
||||
// checkVirtualFields checks the controller's pact.FormVirtualFields list
|
||||
// against the form: every `type: password` field must be listed (its value is
|
||||
// never a model column), and every listed name must be a field of the form
|
||||
// with a scalar type.
|
||||
func checkVirtualFields(cc *CompiledController, fields map[string]FormField) error {
|
||||
for _, field := range cc.Form.Fields {
|
||||
if field.Type == "password" && !cc.virtual[field.Name] {
|
||||
return fmt.Errorf("field %s: type password needs the controller to list it in FormVirtualFields", field.Name)
|
||||
}
|
||||
}
|
||||
src, ok := cc.Controller.(pact.FormVirtualFields)
|
||||
if !ok || src == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range src.FormVirtualFields() {
|
||||
if seen[name] {
|
||||
return fmt.Errorf("FormVirtualFields lists field %s twice", name)
|
||||
}
|
||||
seen[name] = true
|
||||
field, exists := fields[name]
|
||||
if !exists {
|
||||
return fmt.Errorf("FormVirtualFields: field %s is not a field of this form", name)
|
||||
}
|
||||
if !virtualFieldTypes[field.Type] {
|
||||
return fmt.Errorf("FormVirtualFields: field %s has type %s (want password, text, textarea, number, checkbox, switch or dropdown)", name, field.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compilePartials reads and parses every partial the controller declares:
|
||||
// config_list.yaml headerPartial, config_form.yaml preview.headerPartial and
|
||||
// each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ func TestRecordActionSmoke(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("create and update responses carry no actions", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh"}`, "bearer")
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
|
||||
if strings.Contains(rec.Body.String(), `"actions"`) {
|
||||
t.Fatalf("create response: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -38,13 +40,35 @@ type rosterPerson struct {
|
||||
Active bool `gorm:"column:active"`
|
||||
Banned bool `gorm:"column:banned"`
|
||||
// JoinedIP is shown on the preview screen only (context: preview).
|
||||
JoinedIP *string `gorm:"column:joined_ip"`
|
||||
JoinedIP *string `gorm:"column:joined_ip"`
|
||||
// Password is a stored hash. The form's password field is virtual: the
|
||||
// controller's hooks derive this column from the submitted value.
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (rosterPerson) TableName() string { return "roster_people" }
|
||||
func (rosterPerson) Fillable() []string { return []string{"name", "email"} }
|
||||
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func (rosterPerson) TableName() string { return "roster_people" }
|
||||
func (rosterPerson) Fillable() []string { return []string{"name", "email", "slug"} }
|
||||
|
||||
// Rules are the model's own (sign-up) rules: every save needs a confirmed
|
||||
// password. The admin form replaces them through the controller's FormRules.
|
||||
func (rosterPerson) Rules() map[string]string {
|
||||
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
||||
}
|
||||
|
||||
// rosterHash is the fixture's stand-in for a password hash.
|
||||
func rosterHash(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// rosterVirtual is what one Form hook read from VirtualFieldsFromContext.
|
||||
type rosterVirtual struct {
|
||||
Hook string
|
||||
Values map[string]any
|
||||
Found bool
|
||||
}
|
||||
|
||||
// rosterSpy records what each registered action's Run receives.
|
||||
type rosterSpy struct {
|
||||
@@ -53,6 +77,31 @@ type rosterSpy struct {
|
||||
record []pact.AdminRecordActionInput
|
||||
// states counts ListRowStates calls and keeps the size of each page.
|
||||
states []int
|
||||
// virtual keeps what each Form hook read from the context.
|
||||
virtual []rosterVirtual
|
||||
}
|
||||
|
||||
func (s *rosterSpy) recordVirtual(hook string, ctx context.Context) map[string]any {
|
||||
values, found := cabana.VirtualFieldsFromContext(ctx)
|
||||
if s == nil {
|
||||
return values
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
kept := make(map[string]any, len(values))
|
||||
for name, value := range values {
|
||||
kept[name] = value
|
||||
}
|
||||
s.virtual = append(s.virtual, rosterVirtual{Hook: hook, Values: kept, Found: found})
|
||||
return values
|
||||
}
|
||||
|
||||
func (s *rosterSpy) takeVirtual() []rosterVirtual {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.virtual
|
||||
s.virtual = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *rosterSpy) recordStates(n int) {
|
||||
@@ -216,9 +265,49 @@ const rosterLocked = "Locked"
|
||||
// copy and never write into it.
|
||||
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
|
||||
|
||||
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
|
||||
// the field, and fails with a plain error for the name Boom.
|
||||
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
|
||||
// FormVirtualFields lists the form fields that are not columns of the form:
|
||||
// the password pair and the create-only notify checkbox.
|
||||
func (rosterController) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", "notify"}
|
||||
}
|
||||
|
||||
// FormRules are the admin form's rules: a create needs a confirmed password,
|
||||
// an update takes one only when it is submitted.
|
||||
func (rosterController) FormRules(_ context.Context, op string) map[string]string {
|
||||
if op == "create" {
|
||||
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
||||
}
|
||||
return map[string]string{"name": "required", "password": "nullable|between:8,255|confirmed"}
|
||||
}
|
||||
|
||||
// storePassword derives the stored hash from a submitted password.
|
||||
func storePassword(person *rosterPerson, values map[string]any) {
|
||||
if plain, ok := values["password"].(string); ok && plain != "" {
|
||||
person.Password = rosterHash(plain)
|
||||
}
|
||||
}
|
||||
|
||||
// FormBeforeCreate stamps the tenant and stores the hash of the submitted
|
||||
// password. It also drops notify from its own copy of the virtual values: the
|
||||
// after hook must still see it.
|
||||
func (c rosterController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
values := c.spy.recordVirtual("before-create", ctx)
|
||||
model.(*rosterPerson).Tenant = "acme"
|
||||
storePassword(model.(*rosterPerson), values)
|
||||
delete(values, "notify")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c rosterController) FormAfterCreate(ctx context.Context, _ any) error {
|
||||
c.spy.recordVirtual("after-create", ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormBeforeUpdate stores a submitted password, refuses the reserved name
|
||||
// with a ForbiddenError naming the field, and fails with a plain error for
|
||||
// the name Boom.
|
||||
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
|
||||
switch model.(*rosterPerson).Name {
|
||||
case "Reserved":
|
||||
return &cabana.ForbiddenError{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -93,7 +96,7 @@ func TestPreviewSmoke(t *testing.T) {
|
||||
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
|
||||
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
|
||||
}
|
||||
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer")
|
||||
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
|
||||
created := rosterRecord(t, rec.Body.Bytes())
|
||||
id, _ := created.Data["id"].(float64)
|
||||
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
|
||||
@@ -136,3 +139,250 @@ func TestPreviewSmoke(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rosterFields is the fixture's fields.yaml with old replaced by new (boot
|
||||
// tests); an empty old appends new.
|
||||
func rosterFields(t *testing.T, old, new string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(rosterDir, rosterFieldsFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
if old == "" {
|
||||
return text + new
|
||||
}
|
||||
if !strings.Contains(text, old) {
|
||||
t.Fatalf("fields.yaml does not contain %q", old)
|
||||
}
|
||||
return strings.Replace(text, old, new, 1)
|
||||
}
|
||||
|
||||
const rosterFieldsFile = "models/person/fields.yaml"
|
||||
|
||||
// rosterErrorDetail asserts a 4xx body's code and one field message.
|
||||
func rosterErrorDetail(t *testing.T, raw []byte, code, field, message string) {
|
||||
t.Helper()
|
||||
var body cabana.ErrorEnvelope
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("error body %s: %v", raw, err)
|
||||
}
|
||||
if body.Error.Code != code {
|
||||
t.Fatalf("code = %q, want %s; body %s", body.Error.Code, code, raw)
|
||||
}
|
||||
list, _ := body.Error.Details[field].([]any)
|
||||
for _, item := range list {
|
||||
if item == message {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("details[%s] = %v, want %q; body %s", field, body.Error.Details[field], message, raw)
|
||||
}
|
||||
|
||||
// TestPasswordFieldSmoke drives `type: password` through the assembled router
|
||||
// on PostgreSQL (D-27 G1; T-12.1-10): the value reaches the controller's hook,
|
||||
// which stores a hash, and no record response on any route carries the key or
|
||||
// the plain text.
|
||||
func TestPasswordFieldSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
const plain, next = "s3cret-plain-text", "another-plain-9"
|
||||
leaks := func(t *testing.T, route, body string) {
|
||||
t.Helper()
|
||||
for _, part := range []string{"password", plain, next, "sha256:"} {
|
||||
if strings.Contains(body, part) {
|
||||
t.Fatalf("%s response carries %q: %s", route, part, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople,
|
||||
fmt.Sprintf(`{"name":"Pat","password":%q,"password_confirmation":%q}`, plain, plain), "bearer")
|
||||
leaks(t, "create", rec.Body.String())
|
||||
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
||||
id := uint(idFloat)
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(plain) || stored.Password == plain {
|
||||
t.Fatalf("stored password = %q", stored.Password)
|
||||
}
|
||||
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
|
||||
leaks(t, "show", rec.Body.String())
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
|
||||
leaks(t, "list", rec.Body.String())
|
||||
|
||||
rec = env.expect(t, http.StatusOK, http.MethodPut, record,
|
||||
fmt.Sprintf(`{"name":"Pat B","password":%q,"password_confirmation":%q}`, next, next), "bearer")
|
||||
leaks(t, "update", rec.Body.String())
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat B" {
|
||||
t.Fatalf("after update: %+v", stored)
|
||||
}
|
||||
|
||||
t.Run("an update without a password keeps the stored one", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Pat C"}`, "bearer")
|
||||
leaks(t, "update", rec.Body.String())
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a mismatch, a lone confirmation and a short password are 422 on password", func(t *testing.T) {
|
||||
const mismatch = "The password confirmation does not match."
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":"Pat D","password":"long-enough-1","password_confirmation":"long-enough-2"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password_confirmation":"long-enough-2"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":"short","password_confirmation":"short"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password must be between 8 and 255 characters.")
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Mis","password":"long-enough-1","password_confirmation":"other-enough-1"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
|
||||
// Nothing of the refused saves was written.
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
|
||||
t.Fatalf("a refused save wrote: %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the schema serves the field without a value", func(t *testing.T) {
|
||||
_, raw := rosterFormSchema(t, env, "bearer")
|
||||
if !strings.Contains(raw, `"name":"password","type":"password","label":"Password","span":"left","context":["create","update"]`) {
|
||||
t.Fatalf("password field is not in the schema: %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestVirtualFieldsSmoke drives pact.FormVirtualFields (D-27 G2; T-12.1-09):
|
||||
// submitted values reach the Form hooks through VirtualFieldsFromContext only
|
||||
// when the field's context allows the operation, and are never filled or
|
||||
// returned.
|
||||
func TestVirtualFieldsSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
if _, ok := cabana.VirtualFieldsFromContext(context.Background()); ok {
|
||||
t.Fatal("virtual fields reported outside a save")
|
||||
}
|
||||
const body = `{"name":"Vic","notify":true,"password":"long-enough-1","password_confirmation":"long-enough-1"}`
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, body, "bearer")
|
||||
for _, name := range []string{"notify", "password", "password_confirmation"} {
|
||||
if strings.Contains(rec.Body.String(), name) {
|
||||
t.Fatalf("create response carries %s: %s", name, rec.Body.String())
|
||||
}
|
||||
}
|
||||
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, uint(idFloat))
|
||||
|
||||
seen := env.spy.takeVirtual()
|
||||
if len(seen) != 2 || seen[0].Hook != "before-create" || seen[1].Hook != "after-create" {
|
||||
t.Fatalf("hooks = %+v", seen)
|
||||
}
|
||||
for _, hook := range seen {
|
||||
// The before hook deleted notify from its copy; the after hook
|
||||
// still sees it.
|
||||
if !hook.Found || hook.Values["notify"] != true || hook.Values["password"] != "long-enough-1" || hook.Values["password_confirmation"] != "long-enough-1" || len(hook.Values) != 3 {
|
||||
t.Fatalf("%s saw %+v found=%v", hook.Hook, hook.Values, hook.Found)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("a field whose context hides it on update never reaches the hook", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Vic B","notify":true}`, "bearer")
|
||||
if strings.Contains(rec.Body.String(), "notify") {
|
||||
t.Fatalf("update response: %s", rec.Body.String())
|
||||
}
|
||||
seen := env.spy.takeVirtual()
|
||||
if len(seen) != 1 || seen[0].Hook != "before-update" || !seen[0].Found || len(seen[0].Values) != 0 {
|
||||
t.Fatalf("update hook saw %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nested value is 422 on the field and nothing is written", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Nest","notify":{"on":true},"password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field has an invalid value.")
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":["a","b"]}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field has an invalid value.")
|
||||
var count int64
|
||||
if err := gdb.Model(&rosterPerson{}).Where("name = ?", "Nest").Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("nested create wrote %d rows err=%v", count, err)
|
||||
}
|
||||
if seen := env.spy.takeVirtual(); len(seen) != 0 {
|
||||
t.Fatalf("a refused save reached a hook: %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a virtual name is never a fill key", func(t *testing.T) {
|
||||
// The model has a password column; the submitted text must reach it
|
||||
// only through the hook (as a hash), never through Fill.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"password":"plain-through-fill","password_confirmation":"plain-through-fill"}`, "bearer")
|
||||
var stored rosterPerson
|
||||
if err := gdb.Unscoped().First(&stored, uint(idFloat)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Password != rosterHash("plain-through-fill") {
|
||||
t.Fatalf("stored password = %q", stored.Password)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormRulesSmoke drives pact.FormRules (D-28 G5): the controller's rule
|
||||
// set per operation replaces the model's Rules() for admin saves.
|
||||
func TestFormRulesSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Rae", Active: true, Password: rosterHash("stored-before")})
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
||||
|
||||
t.Run("an update of name alone passes although the model demands a confirmed password", func(t *testing.T) {
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Rae B"}`, "bearer")
|
||||
if stored := rosterLoad(t, gdb, id); stored.Name != "Rae B" || stored.Password != rosterHash("stored-before") {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the create rules need a password and the update rules a name", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"No password"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":""}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
|
||||
})
|
||||
|
||||
t.Run("boot rules", func(t *testing.T) {
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " secret:\n type: password\n")},
|
||||
"field secret: type password needs the controller to list it in FormVirtualFields", "acme.roster.people", rosterFieldsFile)
|
||||
// A form-only text field the controller does not list is still a
|
||||
// column error.
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " nickname:\n type: text\n")},
|
||||
"field nickname is not a model column")
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " notify:\n label: acme.roster::lang.people.notify\n type: checkbox\n default: true\n context: create\n", "")},
|
||||
"FormVirtualFields: field notify is not a field of this form", rosterFieldsFile)
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: partial\n path: status\n")},
|
||||
"FormVirtualFields: field notify has type partial")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPresetSchema checks the fields.yaml preset key (D-27 G7): the schema
|
||||
// carries it and its boot rules hold.
|
||||
func TestPresetSchema(t *testing.T) {
|
||||
env, _ := newRosterEnv(t)
|
||||
_, raw := rosterFormSchema(t, env, "bearer")
|
||||
if !strings.Contains(raw, `"name":"slug","type":"text","label":"Slug","preset":{"field":"name","type":"slug"}`) {
|
||||
t.Fatalf("preset is not in the schema: %s", raw)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, old, new string
|
||||
want string
|
||||
}{
|
||||
{"mapping with exact", " preset: name\n", " preset:\n field: name\n type: exact\n", ""},
|
||||
{"unsupported type", " preset: name\n", " preset:\n field: name\n type: camel\n", "preset type camel is not supported (want slug or exact)"},
|
||||
{"unknown key", " preset: name\n", " preset:\n field: name\n prefix: x\n", "preset: unknown field prefix"},
|
||||
{"not a text target", " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n", "preset is only valid on type: text"},
|
||||
{"unknown source", " preset: name\n", " preset: title\n", "field slug: preset field title is not a field of this form"},
|
||||
{"source is not text", " preset: name\n", " preset: notify\n", "field slug: preset field notify must be a text field"},
|
||||
{"itself", " preset: name\n", " preset: slug\n", "field slug: preset names the field itself"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
replace := map[string]string{rosterFieldsFile: rosterFields(t, tc.old, tc.new)}
|
||||
if tc.want == "" {
|
||||
if err := rosterBoot(t, rosterTree(t, replace)); err != nil {
|
||||
t.Fatalf("did not boot: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
rosterBootFails(t, replace, tc.want, rosterFieldsFile)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,7 +1131,7 @@ func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *Compile
|
||||
return &CapabilityError{ControllerID: controllerID(form)}
|
||||
}
|
||||
rules := map[string]string{}
|
||||
for key, rule := range mergedRules(form, pivot, "") {
|
||||
for key, rule := range mergedRules(ctx, form, pivot, "") {
|
||||
if slices.Contains(allowed, key) {
|
||||
rules[key] = rule
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *Compi
|
||||
return &CapabilityError{ControllerID: controllerID(form)}
|
||||
}
|
||||
}
|
||||
rules := mergedRules(form, model, op)
|
||||
rules := mergedRules(ctx, form, model, op)
|
||||
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
|
||||
if err != nil {
|
||||
return &CapabilityError{ControllerID: controllerID(form)}
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
// plugin extensions need a parent record scope a child modal does not have.
|
||||
var relationFormRefusedTypes = map[string]bool{
|
||||
"relation": true, "relation-manager": true, "widget": true, "partial": true,
|
||||
// A password is a virtual field, and only an admin controller lists those.
|
||||
"password": true,
|
||||
}
|
||||
|
||||
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].
|
||||
@@ -152,6 +154,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
if relationFormRefusedTypes[field.Type] {
|
||||
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose))
|
||||
}
|
||||
if field.Preset != nil {
|
||||
return nil, fail(fmt.Errorf("field %s: preset is not supported in a relation form (%s)", field.Name, purpose))
|
||||
}
|
||||
if cr.hasMany() && field.Name == cr.Contract.ForeignKey {
|
||||
return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name))
|
||||
}
|
||||
|
||||
@@ -301,10 +301,23 @@ type FormField struct {
|
||||
// managed before the record is first saved (RelationSchema.Deferrable):
|
||||
// the SPA shows it on the create screen.
|
||||
Deferrable bool `json:"deferrable,omitempty"`
|
||||
// Preset makes a text field follow another field of the form while the
|
||||
// administrator has not edited it, on create only (fields.yaml preset).
|
||||
Preset *FieldPreset `json:"preset,omitempty"`
|
||||
|
||||
optionsMethod string
|
||||
}
|
||||
|
||||
// FieldPreset is a text field's `preset`: Field names the text field of the
|
||||
// same form whose value it follows, and Type is how the value is taken over,
|
||||
// slug (lower-case ASCII with hyphens) or exact (the same text). The admin SPA
|
||||
// applies it on the create form until the administrator edits the field; the
|
||||
// server does not fill the field.
|
||||
type FieldPreset struct {
|
||||
Field string `json:"field"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// ThumbOptions is a fileupload field's thumbOptions mapping. Mode is one of
|
||||
// auto, exact, crop or fit.
|
||||
type ThumbOptions struct {
|
||||
|
||||
@@ -63,9 +63,12 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
|
||||
}
|
||||
for _, field := range fields {
|
||||
// A settings screen has no admin controller to own actions or view models.
|
||||
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" {
|
||||
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
|
||||
}
|
||||
if field.Preset != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: preset is not supported on a settings form", item.Code, field.Name)
|
||||
}
|
||||
}
|
||||
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
|
||||
columns := modelColumns(model)
|
||||
|
||||
@@ -24,3 +24,7 @@ people:
|
||||
deleted_text: An archived person is hidden from the directory.
|
||||
inactive_title: This person is not active
|
||||
inactive_text: Activate the person to let them sign in.
|
||||
slug: Slug
|
||||
password: Password
|
||||
password_confirmation: Repeat the password
|
||||
notify: Send a welcome message
|
||||
|
||||
@@ -24,3 +24,7 @@ people:
|
||||
deleted_text: Zarchiwizowana osoba jest ukryta w katalogu.
|
||||
inactive_title: Ta osoba jest nieaktywna
|
||||
inactive_text: Aktywuj osobę, aby mogła się zalogować.
|
||||
slug: Slug
|
||||
password: Hasło
|
||||
password_confirmation: Powtórz hasło
|
||||
notify: Wyślij wiadomość powitalną
|
||||
|
||||
@@ -7,6 +7,25 @@ fields:
|
||||
label: acme.roster::lang.people.email
|
||||
type: text
|
||||
span: right
|
||||
slug:
|
||||
label: acme.roster::lang.people.slug
|
||||
type: text
|
||||
preset: name
|
||||
password:
|
||||
label: acme.roster::lang.people.password
|
||||
type: password
|
||||
span: left
|
||||
context: [create, update]
|
||||
password_confirmation:
|
||||
label: acme.roster::lang.people.password_confirmation
|
||||
type: password
|
||||
span: right
|
||||
context: [create, update]
|
||||
notify:
|
||||
label: acme.roster::lang.people.notify
|
||||
type: checkbox
|
||||
default: true
|
||||
context: create
|
||||
joined_ip:
|
||||
label: acme.roster::lang.people.joined_ip
|
||||
type: text
|
||||
|
||||
@@ -31,3 +31,37 @@ func TxFromContext(ctx context.Context) (*gorm.DB, bool) {
|
||||
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
|
||||
return tx, ok && tx != nil
|
||||
}
|
||||
|
||||
type virtualFieldsKey struct{}
|
||||
|
||||
// withVirtualFields returns ctx carrying the virtual field values of a save.
|
||||
func withVirtualFields(ctx context.Context, values map[string]any) context.Context {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
return context.WithValue(ctx, virtualFieldsKey{}, values)
|
||||
}
|
||||
|
||||
// VirtualFieldsFromContext returns the values an administrator submitted for
|
||||
// the form's virtual fields (pact.FormVirtualFields), keyed by field name, for
|
||||
// the context handed to a Form hook (pact.FormBeforeCreate,
|
||||
// pact.FormAfterCreate, pact.FormBeforeUpdate, pact.FormAfterUpdate). Only
|
||||
// fields that were present in the request body and whose `context` allows the
|
||||
// operation are in the map, so a missing key means "not submitted". Values
|
||||
// are scalars as decoded from the JSON body: a string, a bool, a json.Number
|
||||
// or nil. The map is a copy. The second result is false outside a create or
|
||||
// update save.
|
||||
func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
|
||||
if ctx == nil {
|
||||
return nil, false
|
||||
}
|
||||
values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]any, len(values))
|
||||
for name, value := range values {
|
||||
out[name] = value
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ form:
|
||||
create: Create
|
||||
return_to_list: Back to list
|
||||
return_to_preview: Back to preview
|
||||
show_password: Show password
|
||||
hide_password: Hide password
|
||||
close: Close
|
||||
confirm: Confirm
|
||||
saving: Saving…
|
||||
|
||||
@@ -74,6 +74,8 @@ form:
|
||||
create: Utwórz
|
||||
return_to_list: Wróć do listy
|
||||
return_to_preview: Wróć do podglądu
|
||||
show_password: Pokaż hasło
|
||||
hide_password: Ukryj hasło
|
||||
close: Zamknij
|
||||
confirm: Potwierdź
|
||||
saving: Zapisywanie…
|
||||
|
||||
Reference in New Issue
Block a user