diff --git a/modules/pact/README.md b/modules/pact/README.md index 84afcdb..050bb8e 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -15,6 +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`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. - 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). +- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices. - 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. @@ -118,6 +119,14 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. | | `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. | | `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | +| `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. | +| `pact.FormFieldType` | One field type: the alias `Name`, the custom-element tag `Element`, `Translatable`, the `Scripts` and `Styles` under `assets/`, the `Options` keys, and the funcs `CheckOptions` (optional), `Decode` (required), `Load`, `ListValue` (`func(ctx context.Context, value any) string`) and `Upload` (optional). | +| `pact.FormFieldValue` | What `Decode` and `Load` receive: the transaction, the record, the field, its options, the content locale (translatable types), whether the value lives in the host column, the value and an exact reader of another attribute in the same locale. | +| `pact.FormFieldDecoded` | `Decode`'s answer: the `Value` to store (a string for a translatable type) and optional `Notices`. | +| `pact.FormFieldNotice` | A message about a saved value: a phrase key or text with params; a positive `Count` selects the plural form. | +| `pact.FormFieldUpload` | What `Upload` receives: the transaction, the scoped record (nil on the create form), the field, its options, the file name and the capped body. | +| `pact.FormFieldUploadResult` | `Upload`'s answer: the URL of the stored file. | +| `pact.FormFieldError` | A value or upload a field type refuses: messages shown on the field (validation failed). | | `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. | @@ -137,7 +146,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { - SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`). - Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`. -- Standard library: `context`, `io/fs`, `net/http`, `time`. +- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `strings`, `time`. ## Testing diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index ab81562..5720451 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -3,8 +3,10 @@ package pact import ( "context" "encoding/json" + "io" "io/fs" "net/http" + "strings" "time" "git.golem15.com/golem15/summercms/modules/bonfire" @@ -368,6 +370,126 @@ type AdminPartialData interface { PartialData(ctx context.Context, name string, record any) (any, error) } +// FormFieldType is one admin form field type a plugin registers under a +// short global alias, used as `type:` in any controller's fields.yaml. The +// admin framework owns the HTTP routes, the CSRF check, authentication, +// permissions, record scope, locale storage, notice localization and the +// skip of a value the administrator did not change; the funcs only carry the +// type's own logic. +// +// Name is the alias: lowercase ASCII letters and digits starting with a +// letter, unique across plugins and never a built-in field type. Element is +// the custom-element tag the admin renders for the field; it must start with +// the owning plugin's prefix (vendor.plugin -> "vendor-plugin-") and is +// defined by one of Scripts. Translatable makes the value a map of locale to +// string, stored through the framework's translation writer: the default +// locale in the host column, the others as translations. Scripts (.js, .mjs) +// and Styles (.css) are paths under assets/ in the plugin's AdminFS; the admin +// loads them once on every admin page. Options are the type's own fields.yaml +// keys, next to the general field keys. +// +// CheckOptions, when set, checks a field's options at boot; an error fails +// boot naming the field. Decode is required: it turns a submitted value into +// the value to store and may report what it changed as notices; a +// *FormFieldError refuses a value the type cannot accept at all. Load, when +// set, turns a stored value into the value the element receives. ListValue, +// when set, returns the text a list column of this type shows. Upload, when +// set, stores a file the element uploads and returns its URL. +type FormFieldType struct { + Name string + Element string + Translatable bool + Scripts []string + Styles []string + Options []string + CheckOptions func(options map[string]any) error `json:"-"` + Decode func(ctx context.Context, in FormFieldValue) (FormFieldDecoded, error) `json:"-"` + Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` + ListValue func(ctx context.Context, value any) string `json:"-"` + Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` +} + +// FormFieldValue is what the framework hands Decode and Load. DB is the save +// transaction (Decode) or the read transaction (Load). Record is the loaded +// record on update and the new model on create. Options are the field's +// options from fields.yaml. Locale is the content locale of the value for a +// translatable type and empty otherwise; Default is true when the value lives +// in the host column (always true for a type that is not translatable). Value +// is the submitted value (Decode) or the stored value (Load); it is a string +// for a translatable type and any JSON value otherwise. Translated, when set, +// reads another attribute of Record in Locale exactly, without fallback; it is +// nil for a type that is not translatable and when no translation writer is +// published. +type FormFieldValue struct { + DB *gorm.DB + Record any + Field string + Options map[string]any + Locale string + Default bool + Value any + Translated func(attribute string) (string, bool, error) +} + +// FormFieldDecoded is Decode's answer. Value is stored: a string for a +// translatable type. Notices are non-blocking messages about the accepted +// value, such as what a sanitizer removed; a save whose Decode calls return +// no notice answers exactly as a save without plugin field types. +type FormFieldDecoded struct { + Value any + Notices []FormFieldNotice +} + +// FormFieldNotice is one message for the administrator about a saved value. +// The framework localizes Message (a phrase key or text) in the request +// locale with Params; a Count above zero selects the plural form and sets +// :count. +type FormFieldNotice struct { + Message string + Params map[string]string + Count int +} + +// FormFieldUpload is what the framework hands Upload. DB is the upload's +// transaction. Record is the record loaded through the controller's +// FormExtendQuery scope, nil on the create form. Options are the field's +// options. Body is the file's content, already capped by the upload body +// limit. +type FormFieldUpload struct { + DB *gorm.DB + Record any + Field string + Options map[string]any + Filename string + Body io.Reader +} + +// FormFieldUploadResult is Upload's answer: the URL the element shows. +type FormFieldUploadResult struct { + URL string +} + +// FormFieldError is a value a field type refuses. Messages are phrase keys +// or text, localized by the framework and shown on the field; the save or +// upload writes nothing. +type FormFieldError struct { + Messages []string +} + +// Error joins the messages with "; ". +func (e *FormFieldError) Error() string { + if e == nil { + return "" + } + return strings.Join(e.Messages, "; ") +} + +// HasFormFieldTypes is implemented by plugins (not admin controllers) that +// register admin form field types, like HasAdminControllers. +type HasFormFieldTypes interface { + FormFieldTypes() []FormFieldType +} + // Permission is one registerPermissions() entry. type Permission struct { Code string