Files
summercms/modules/pact/capabilities.go
Jakub Zych 23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
2026-10-12 15:35:43 +02:00

911 lines
36 KiB
Go

package pact
import (
"context"
"encoding/json"
"io"
"io/fs"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// HasCommands is implemented by plugins that register console commands.
type HasCommands interface {
Commands() []bonfire.Command
}
// HasConfig is implemented by plugins that ship default YAML configuration.
// ConfigFS should contain files such as config/config.yaml, merged later at
// the plugin ID path.
type HasConfig interface {
ConfigFS() fs.FS
}
// HasMigrations is implemented by plugins that ship an ordered gormigrate
// set. The kernel runs each set in party.Activate order with a separate
// history table per plugin ID.
type HasMigrations interface {
Migrations() []*gormigrate.Migration
}
// Middleware is a named HTTP wrapper registered by a plugin.
type Middleware func(http.Handler) http.Handler
// HasMiddleware is implemented by plugins that register named middleware.
type HasMiddleware interface {
Middlewares() map[string]Middleware
}
// HasMiddlewareFactories is implemented by plugins that register
// parameterized middleware ("name:param"), resolved at wrap time.
type HasMiddlewareFactories interface {
MiddlewareFactories() map[string]func(param string) Middleware
}
// HasHouseMiddleware is implemented by plugins that register middleware
// tagged as house-envelope/error handling -- refused inside a raw group
// (D-16). This is the ONLY way a plugin declares a house-tagged name:
// plugins never call a Router.Register* method directly (there is no such
// call site anywhere in this codebase -- RegisterMiddleware/
// RegisterMiddlewareFactory/RegisterHouseMiddleware are all called
// exclusively from surf.Assemble/BuildRouter's plugin loop, the same way
// HasMiddleware's Middlewares() map is today). A name present in both
// Middlewares() and HouseMiddlewares() (from the same or a different
// plugin) fails boot with the existing duplicate-name error, since both are
// registered into the same underlying name table.
type HasHouseMiddleware interface {
HouseMiddlewares() map[string]Middleware
}
// Router is the Laravel-like group builder implemented by surf.
type Router interface {
Group(prefix string, middleware []string, fn func(Router))
GroupRaw(prefix string, middleware []string, fn func(Router))
Get(path string, handler http.HandlerFunc, middleware ...string)
Post(path string, handler http.HandlerFunc, middleware ...string)
Put(path string, handler http.HandlerFunc, middleware ...string)
Patch(path string, handler http.HandlerFunc, middleware ...string)
Delete(path string, handler http.HandlerFunc, middleware ...string)
Where(param, pattern string)
WhereIn(param string, values ...string)
}
// HasRoutes is implemented by plugins that declare HTTP routes.
type HasRoutes interface {
Routes(Router) error
}
// HasModels is implemented by plugins that expose GORM models.
type HasModels interface {
Models() []any
}
// JobArgs is the typed payload a job worker receives. Kind identifies the
// job so a worker can reject unexpected argument types.
type JobArgs interface {
Kind() string
}
// Job is a unit of background work. Phase 11 adapts this onto River; the
// interface itself must not import River.
type Job interface {
Work(ctx context.Context, args JobArgs) error
}
// HasJobs is implemented by plugins that register background jobs.
type HasJobs interface {
Jobs() []Job
}
type cadenceKind uint8
const (
cadenceNone cadenceKind = iota
cadenceDaily
cadenceEvery
)
// Cadence is how often a scheduled command runs. Build one with Daily,
// DailyAt or Every; the zero value is no cadence and is rejected by the
// scheduler.
type Cadence struct {
kind cadenceKind
hour int
minute int
every time.Duration
}
// Daily runs once a day at 00:00 in the app timezone (Laravel ->daily()).
func Daily() Cadence { return DailyAt(0, 0) }
// DailyAt runs once a day at hour:minute in the app timezone.
func DailyAt(hour, minute int) Cadence {
return Cadence{kind: cadenceDaily, hour: hour, minute: minute}
}
// Every runs at every multiple of d since local midnight in the app
// timezone. The scheduler requires d to be at least one second and to divide
// 24h evenly.
func Every(d time.Duration) Cadence {
return Cadence{kind: cadenceEvery, every: d}
}
// IsZero reports whether c is the zero Cadence.
func (c Cadence) IsZero() bool { return c.kind == cadenceNone }
// Interval is the period of c: 24h for a daily cadence, d for Every(d) and
// zero for the zero Cadence.
func (c Cadence) Interval() time.Duration {
switch c.kind {
case cadenceDaily:
return 24 * time.Hour
case cadenceEvery:
return c.every
}
return 0
}
// At returns the wall-clock time of a daily cadence; ok is false for Every
// and for the zero Cadence.
func (c Cadence) At() (hour, minute int, ok bool) {
if c.kind != cadenceDaily {
return 0, 0, false
}
return c.hour, c.minute, true
}
// ScheduledCommand is one recurring run of a registered console command.
// Command is the command name (namespace:verb) and Args its arguments.
type ScheduledCommand struct {
Command string
Args []string
Cadence Cadence
}
// HasSchedule is implemented by plugins that run console commands on a
// schedule. Only these compiled entries are ever executed by the scheduler.
type HasSchedule interface {
Schedule() []ScheduledCommand
}
// AdminController is the compile-time admin controller contract. Phase 9
// grows the schema pipeline; ID, model name and YAML config directory are
// enough for generated stubs to compile.
type AdminController interface {
ID() string
ModelName() string
ConfigDir() string
}
// HasAdminControllers is implemented by plugins that register admin controllers.
type HasAdminControllers interface {
AdminControllers() []AdminController
}
// AdminAssets is the plugin-owned embedded tree of Winter admin YAML.
// Paths are relative to the plugin root (controllers/..., models/...).
type AdminAssets interface {
AdminFS() fs.FS
}
// AdminPermissioned is the D-03 permission list enforced before schema or SQL.
type AdminPermissioned interface {
RequiredPermissions() []string
}
// AdminRecordSource supplies the GORM model the generic admin handlers query.
// NewRecord returns a pointer to the model struct.
type AdminRecordSource interface {
NewRecord() any
}
// AdminClientAssets is Winter's addJs/addCss for one admin controller. The
// paths are relative to the owning plugin's AdminFS and must live under
// assets/ (for example assets/js/lookup.js). The admin SPA loads them when the
// controller's list or form opens; files are always served from the embedded
// tree, never from disk. It is separate from AdminAssets, which is the YAML
// tree itself.
type AdminClientAssets interface {
AdminJS() []string
AdminCSS() []string
}
// AdminAction is one controller action that a list toolbar button or a form
// widget runs. The admin framework owns the HTTP route, the CSRF check,
// authentication and record scoping; Run only carries the business logic.
// Name is an identifier unique within the controller; create and delete are
// reserved for the built-in toolbar actions. Label is a phrase key or literal
// text used as the button caption. Permissions are checked in addition to the
// controller's RequiredPermissions.
type AdminAction struct {
Name string
Label string
Permissions []string
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"`
}
// AdminActionInput is what the framework hands an AdminAction. Field is the
// widget field name and is empty for a toolbar action. RecordID is nil on the
// create form and always nil for a toolbar action: toolbar actions carry no
// record ids, so an id list can never become an unscoped lookup. Record is the
// record the framework loaded through the controller's FormExtendQuery scope,
// nil when RecordID is nil. Values is the widget's snapshot of its fill
// fields, already reduced to the field's declared fill keys and to scalars.
// Payload is the widget's own payload: the `summer-action` event's
// detail.payload, posted by the SPA as the body key `payload`. It is any JSON
// value up to 64 KiB, exactly the bytes the client sent, nil when the request
// carried none and always nil for a toolbar action. The framework neither
// inspects nor filters it: an action decodes it itself with encoding/json and
// validates any ids it contains against its own scope.
type AdminActionInput struct {
Field string
RecordID *uint64
Record any
Values map[string]any
Payload json.RawMessage
}
// AdminActionResult is an action's answer. Message is a phrase key or text,
// localized by the framework and shown as a toast. Fill is the widget
// write-back; keys outside the field's declared fill keys and non-scalar
// values are dropped before the response is written. Data is structured data
// for the widget: it is written to the response as `data` exactly as it
// encodes (any JSON value, capped at 256 KiB encoded; a larger or unencodable
// value is a 500), is not subject to the fill allowlist, and is omitted when
// nil. The SPA sets it on the element as the `data` attribute and announces it
// with a `summer-result` event.
type AdminActionResult struct {
Message string
Fill map[string]any
Data any
}
// HasAdminActions is implemented by an admin controller that registers named
// actions for its toolbar.buttons list and its `type: widget` form fields.
type HasAdminActions interface {
AdminActions() []AdminAction
}
// AdminBulkAction is one controller action an administrator runs on the rows
// selected in a list (config_list.yaml bulkActions). The admin framework owns
// the HTTP route, the CSRF check, authentication, the resolution of the posted
// ids and the transaction; Run only carries the business logic and reads the
// write transaction with cabana.TxFromContext. Name is an identifier unique
// among the controller's bulk actions; create and delete are reserved, and a
// record action may reuse a bulk action's name. Label and Confirm are phrase
// keys or literal text: Label is the menu item, Confirm the confirmation
// question, and an empty Confirm means the framework's default confirm text.
// Permissions are checked in addition to the controller's
// RequiredPermissions.
type AdminBulkAction struct {
Name string
Label string
Confirm string
Permissions []string
Run func(ctx context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) `json:"-"`
}
// AdminBulkActionInput is what the framework hands an AdminBulkAction.
// Records are pointers to the controller's model, loaded and row-locked
// through the controller's ListExtendQuery scope and ordered by primary key.
// A bulk action never receives ids, so an id list can never become an
// unscoped lookup.
type AdminBulkActionInput struct {
Records []any
}
// AdminBulkActionResult is a bulk action's answer. Message is a phrase key or
// text, localized by the framework and shown as a toast; when it is empty the
// admin shows its default text with Affected. Affected is the number of
// records the action changed, which may be lower than the selection when the
// action skips rows.
type AdminBulkActionResult struct {
Message string
Affected int
}
// HasAdminBulkActions is implemented by an admin controller that registers
// named bulk actions for its config_list.yaml bulkActions list.
type HasAdminBulkActions interface {
AdminBulkActions() []AdminBulkAction
}
// AdminRecordAction is one controller action an administrator runs on a
// single record (config_form.yaml recordActions). The admin framework owns the
// HTTP route, the CSRF check, authentication, the record lookup and the
// transaction; Run only carries the business logic and reads the write
// transaction with cabana.TxFromContext. Name is an identifier unique among
// the controller's record actions; create and delete are reserved, and a bulk
// action may use the same name. Label and Confirm are phrase keys or literal
// text; an empty Confirm means the framework's default confirm text.
// Permissions are checked in addition to the controller's
// RequiredPermissions. Applies reports whether the action applies to the
// record in its current state: a nil Applies means the action always applies.
// Applies must be a pure read, because it also runs when a record is shown, to
// decide which actions the admin is offered.
type AdminRecordAction struct {
Name string
Label string
Confirm string
Permissions []string
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
}
// AdminRecordActionInput is what the framework hands an AdminRecordAction.
// Record is the record the framework loaded through the controller's
// FormExtendQuery scope with a row lock, inside the action's transaction;
// RecordID is its primary key.
type AdminRecordActionInput struct {
RecordID uint64
Record any
}
// AdminRecordActionResult is a record action's answer. Message is a phrase key
// or text, localized by the framework and shown as a toast; when it is empty
// the admin shows its default text.
type AdminRecordActionResult struct {
Message string
}
// HasAdminRecordActions is implemented by an admin controller that registers
// named record actions for its config_form.yaml recordActions list.
type HasAdminRecordActions interface {
AdminRecordActions() []AdminRecordAction
}
// AdminPartialData supplies the view model a controller partial template
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
// is the partial name; record is the scoped record for a form partial on an
// existing record, else nil. The result must be a curated view model built
// for the template, never a GORM model: the framework refuses a view model
// that carries the controller's model or any other GORM model, even nested
// in a field, a collection or an interface value.
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 (the framework refuses any other submitted value,
// null aside, which is the empty string, before Decode runs) 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
Tab string
Label string
Roles []string
}
// HasPermissions is implemented by plugins that declare backend permissions.
type HasPermissions interface {
Permissions() []Permission
}
// TokenScope is one personal API token scope a plugin registers at boot.
// Label and Description are phrase keys (containing "::") or literal text.
type TokenScope struct {
Name string
Label string
Description string
Permissions []string
Methods []string
}
// HasTokenScopes is implemented by plugins that declare personal API token scopes.
type HasTokenScopes interface {
TokenScopes() []TokenScope
}
// NavigationItem is one registerNavigation() entry. Controller is the admin
// controller ID; the SPA derives its route from that ID.
type NavigationItem struct {
Code string
Label string
Icon string
Permissions []string
Order int
Controller string
SideMenu []NavigationItem
}
// HasNavigation is implemented by plugins that declare backend navigation.
type HasNavigation interface {
Navigation() []NavigationItem
}
// SettingsItem is one registerSettings() entry. An item is either a singleton
// settings form (Model, Form and NewModel) or a link to an admin controller
// (Controller), never both.
type SettingsItem struct {
Code string
Label string
Description string
Category string
Icon string
Model string
Order int
Keywords []string
Permissions []string
// Controller, when set, makes the entry a link to that admin controller
// ID (vendor.plugin.controller): the equivalent of a WinterCMS
// registerSettings 'url' => Backend::url(...) entry. A link entry
// declares no Model, Form or NewModel and is not a singleton.
Controller string
Form string `json:"-"`
NewModel func() any `json:"-"`
}
// HasSettings is implemented by plugins that declare settings screens.
type HasSettings interface {
Settings() []SettingsItem
}
// Option is one dropdown choice. Label may be a phrase key until request time.
// Depth is the nesting level of a choice taken from a tree (0 at the top); a
// flat list leaves it 0, so its JSON carries no depth key.
type Option struct {
Value string `json:"value"`
Label string `json:"label"`
Depth int `json:"depth,omitempty"`
}
// DropdownOptionsProvider serves method-backed dropdown options.
type DropdownOptionsProvider interface {
DropdownOptions(field string) []Option
}
// DependentOptionSet is the answer of a dropdown whose choices depend on a
// sibling field. Applies is false when the sibling's value takes no choice at
// all (a menu item type without references, for example): the field then
// holds no value. Options are the choices when Applies is true, possibly none
// yet.
type DependentOptionSet struct {
Applies bool
Options []Option
}
// DependentOptionsProvider is implemented by an admin controller whose form
// has a dropdown with `dependsOn` (WinterCMS's dependent dropdown). field is
// the dropdown's name and values holds the submitted values of the fields it
// depends on, keyed by field name: the sibling fields of the same form, or of
// the same repeater row for a dropdown inside a repeater. The admin framework
// calls it for the options route and again on every save, where a value that
// is not among the options is refused.
type DependentOptionsProvider interface {
DependentOptions(ctx context.Context, field string, values map[string]any) (DependentOptionSet, error)
}
// MenuItemType is one menu item type a plugin registers through
// HasMenuItemTypes, the typed successor of WinterCMS's
// pages.menuitem.listTypes, getTypeInfo and resolveItem events. Menus and
// sitemaps offer it as an item type and resolve their items through it.
//
// Key is unique across plugins (for example "acme-post"); Label is a phrase
// key or text. DefaultPattern is the type's URL pattern, such as
// "/blog/:slug": it is required and starts with "/". PatternFrom is set on a
// list type, whose items are records of another type, to that type's key: the
// host's pattern override of the record type then applies to the list's
// items when the list type has no override of its own. Placeholders names the
// placeholders the pattern understands (":slug", ":id"). Nesting reports that
// an item of the type may expand into its sub-items, and DynamicItems that an
// item generates its children from records. References, when set, returns the
// records an item of the type can point at as a tree; nil means the type takes
// no reference. Resolve is required and turns one item into its URL, title,
// alternates and children.
type MenuItemType struct {
Key string
Label string
DefaultPattern string
PatternFrom string
Placeholders []string
Nesting bool
DynamicItems bool
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"`
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
}
// MenuItemReference is one record a menu item can point at: the stored
// Value, the Label an administrator picks it by, and the records nested under
// it.
type MenuItemReference struct {
Value string
Label string
Items []MenuItemReference
}
// MenuItemResolve is what a type's Resolve receives. Type is the type key and
// Reference the item's stored reference ("" for a type that takes none).
// Pattern is the URL pattern already layered by the registry: the item's own
// override, else the host's override, else the type's default. Nesting asks
// for the item's sub-items. UnprefixedLocale names the locale whose URLs carry
// no locale prefix; "" leaves the choice to the type.
type MenuItemResolve struct {
Type string
Reference string
Pattern string
Nesting bool
UnprefixedLocale string
}
// ResolvedMenuItem is a resolved menu item. URL is empty for a pure list type,
// whose Items are the records it lists. MTime is the record's last change,
// nil when unknown. Alternates lists the item's URL per locale in the locale
// order of the site, never as a map, so hreflang output is stable; a site with
// one locale resolves to one alternate.
type ResolvedMenuItem struct {
Title string
URL string
MTime *time.Time
Alternates []MenuItemAlternate
Items []ResolvedMenuItem
}
// MenuItemAlternate is the URL and title of a resolved item in one locale.
type MenuItemAlternate struct {
Locale string
URL string
Title string
}
// HasMenuItemTypes is implemented by plugins that register menu item types.
// The framework collects every plugin's types once at boot, after Register
// and before Boot, and fails the boot on a duplicate key.
type HasMenuItemTypes interface {
MenuItemTypes() []MenuItemType
}
// AdminSingleton is implemented by an admin controller that edits exactly one
// record, such as a site-wide definition opened from the settings page.
// SingletonRecord returns that record, creating it first when it does not
// exist yet (first or create), using db, the request's transaction. The admin
// framework then opens the record's form directly; the controller's list,
// create, bulk and delete routes answer 404, and it needs no
// config_list.yaml.
type AdminSingleton interface {
SingletonRecord(ctx context.Context, db *gorm.DB) (any, error)
}
// ListExtendQuery optionally narrows the admin list query.
type ListExtendQuery interface {
ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
}
// RowState is one state an admin list row may carry. The set is fixed; the
// admin shows each state as a text badge and a row text style.
type RowState string
// The row states the admin understands.
const (
// RowStateDeleted marks a soft-deleted record.
RowStateDeleted RowState = "deleted"
// RowStateNegative marks a record in a blocked or otherwise negative state.
RowStateNegative RowState = "negative"
// RowStateDisabled marks an inactive record.
RowStateDisabled RowState = "disabled"
)
// ListRowStates optionally marks the rows of an admin list with states. The
// framework calls it once per list page with the page's records, in page
// order, and the list's database handle: a list does not run in a
// transaction, so cabana.TxFromContext reports none here. The result is
// index-aligned with records; a row may carry several states and a nil entry
// means none. Values outside the three RowState constants are dropped.
type ListRowStates interface {
ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error)
}
// ListRelationColumnMapper maps a source-schema relation column onto the
// physical column exposed by the related Go model. It supports legacy admin
// schemas whose public field names no longer match the database schema.
type ListRelationColumnMapper interface {
ListRelationColumn(relation, column string) (string, bool)
}
// FormExtendQuery optionally narrows admin form record lookup.
type FormExtendQuery interface {
FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
}
// FormBeforeCreate optionally rejects or stamps a record before insert.
type FormBeforeCreate interface {
FormBeforeCreate(ctx context.Context, model any) error
}
// FormBeforeUpdate optionally rejects or stamps a record before update.
type FormBeforeUpdate interface {
FormBeforeUpdate(ctx context.Context, model any) error
}
// FormAfterCreate optionally runs after insert, inside the same transaction.
type FormAfterCreate interface {
FormAfterCreate(ctx context.Context, model any) error
}
// FormAfterUpdate optionally runs after update, inside the same transaction.
type FormAfterUpdate interface {
FormAfterUpdate(ctx context.Context, model any) error
}
// FormBeforeDelete optionally rejects a record before delete.
type FormBeforeDelete interface {
FormBeforeDelete(ctx context.Context, model any) error
}
// FormAfterDelete optionally runs after delete, inside the same transaction.
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
}
// RelationExtendOptionsQuery optionally narrows the rows a form relation
// field offers (D-17). The same scoped query revalidates submitted ids on
// save, so a row it does not return cannot be attached (D-18).
type RelationExtendOptionsQuery interface {
RelationExtendOptionsQuery(ctx context.Context, field string, db *gorm.DB) *gorm.DB
}
// RelationBeforeLink optionally stamps pivot columns before a link insert.
type RelationBeforeLink interface {
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
}
// The six relation child hooks below are optional admin-controller
// capabilities for a relation manager that creates, updates or deletes
// related records. Before hooks run inside the child write's transaction
// before the row is written; After hooks run after the write and before the
// commit. parent is the loaded parent record, or a fresh record with a zero
// key while the parent is not saved yet (deferral). child is the related
// record being written. An error rolls the write back and the admin API
// answers its opaque lifecycle error.
// RelationBeforeCreate optionally runs before a related record is created
// through a relation manager.
type RelationBeforeCreate interface {
RelationBeforeCreate(ctx context.Context, relation string, parent, child any) error
}
// RelationAfterCreate optionally runs after a related record is created
// through a relation manager, before the commit.
type RelationAfterCreate interface {
RelationAfterCreate(ctx context.Context, relation string, parent, child any) error
}
// RelationBeforeUpdate optionally runs before a related record is updated
// through a relation manager.
type RelationBeforeUpdate interface {
RelationBeforeUpdate(ctx context.Context, relation string, parent, child any) error
}
// RelationAfterUpdate optionally runs after a related record is updated
// through a relation manager, before the commit.
type RelationAfterUpdate interface {
RelationAfterUpdate(ctx context.Context, relation string, parent, child any) error
}
// RelationBeforeDelete optionally runs before a related record is deleted
// through a relation manager.
type RelationBeforeDelete interface {
RelationBeforeDelete(ctx context.Context, relation string, parent, child any) error
}
// RelationAfterDelete optionally runs after a related record is deleted
// through a relation manager, before the commit.
type RelationAfterDelete interface {
RelationAfterDelete(ctx context.Context, relation string, parent, child any) error
}
// FilterScope is the model capability a config_filter scope name may call.
// FilterScopes is the exact, case-sensitive set of names; request text never
// selects a method outside that set.
type FilterScope interface {
FilterScopes() []string
FilterScope(name string, db *gorm.DB, value any) *gorm.DB
}
// FilterOptions serves the choices of a model-backed config_filter scope
// (D-27). It receives the filter's scope method name; labels may be phrase
// keys. The admin controller may implement it and is asked first, so choices
// that are read from the database can use the handle the controller holds;
// otherwise the model that implements FilterScope is asked. The scope itself
// is always the model's FilterScope.
type FilterOptions interface {
FilterOptions(scope string) []Option
}
// HasLang is implemented by plugins that ship embedded translation YAML
// under lang/<locale>/<group>.yaml.
type HasLang interface {
LangFS() fs.FS
}
// HasLangOverrides is implemented by plugins (usually the app plugin) that
// replace or add translations of any loaded namespace, including the
// framework's backend::lang admin strings, and may add locales (D-20).
// Layout: lang/<locale>/<namespace>/<group>.yaml, e.g.
// lang/pl/backend/lang.yaml.
type HasLangOverrides interface {
LangOverridesFS() fs.FS
}
// HasMailTemplates is implemented by plugins that ship Winter-shaped mail
// templates and layout aliases. MailTemplatesFS contains views/mail assets;
// MailTemplates lists dotted template names; MailLayouts maps a short layout
// name to the full dotted layout name.
type HasMailTemplates interface {
MailTemplatesFS() fs.FS
MailTemplates() []string
MailLayouts() map[string]string
}
// OptionalMessage is a service an optional plugin may publish so other
// plugins can integrate without importing that plugin's package.
type OptionalMessage interface {
Message() string
}
// Future capability families are type-asserted when their first consumer
// packages exist:
//
// HasListeners
//
// The kernel type-asserts HasConfig (party, before Register), HasCommands
// (generated app main, after Boot), HasMigrations (lagoon migrate),
// HasJobs and HasSchedule (conga workers), and
// HasMiddleware/HasMiddlewareFactories/HasHouseMiddleware/HasRoutes
// (surf assemble). surf.BucketProvider is type-asserted in Assemble/
// BuildRouter (not a pact interface: pact cannot import surf without a cycle).