feat(quick-261016-nqk): ListExtendRecords and FormRecordTitle admin hooks (G-18.1-4)

pact.ListExtendRecords fills unstored fields of one list page before its
columns are read (WinterCMS listExtendRecords); such columns compile
unsortable and refuse sortable/searchable. pact.FormRecordTitle names a
record; the show, create and update responses send it as meta.title.
This commit is contained in:
Jakub Zych
2026-10-16 17:19:54 +02:00
parent 9890aff66d
commit bc5685eb93
18 changed files with 360 additions and 4 deletions

View File

@@ -31,6 +31,8 @@ Use cases:
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count. A detached action (`pact.AdminBulkAction.Detached`) opens no transaction: its records are loaded through the same scope and rules without row locks, `TxFromContext` reports none, and `Run` opens its own short transactions, so no transaction or lock is held across an external call.
- Invisible columns and filter choices: `invisible: true` in `columns.yaml` keeps a column searchable and sortable on the server while the schema flags it, the SPA does not render it and list rows do not carry it. The choices of a scope filter are served by the controller when it implements `pact.FilterOptions`, else by the model.
- Computed columns: a controller implementing `pact.ListExtendRecords` is called once per list page, after the records are loaded and before their columns are read, so a column of a field gorm ignores (a `-` tag with a column name) shows the value the hook set. Such a column compiles unsortable; `sortable: true` or `searchable: true` on it fails the boot. A hook error fails the list with the generic 500.
- Record title: a controller implementing `pact.FormRecordTitle` names a record; the show, create and update responses carry it as `meta.title` (`RecordMeta.Title`), and the admin heads the form and preview screens with it instead of the first text field's value.
- 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.

View File

@@ -611,6 +611,15 @@ func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController,
if err := projectPermissionFields(withTx(ctx, tx), cc, model, data); err != nil {
return RecordResult{}, err
}
if cc != nil {
if hook, ok := cc.Controller.(pact.FormRecordTitle); ok && hook != nil {
title, err := hook.FormRecordTitle(ctx, tx, model)
if err != nil {
return RecordResult{}, lifecycleFailure(cc, err)
}
meta.Title = title
}
}
return RecordResult{Data: data, Meta: meta}, nil
}

View File

@@ -0,0 +1,46 @@
package cabana_test
import (
"context"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Ticket is a support ticket. OwnerEmail is not stored: the list's
// owner_email column shows what ListExtendRecords puts there.
type Ticket struct {
ID uint `gorm:"column:id;primaryKey"`
OwnerID uint `gorm:"column:owner_id"`
OwnerEmail string `gorm:"-:all;column:owner_email"`
}
// TicketsController is an admin controller with a computed column.
type TicketsController struct{}
var _ pact.ListExtendRecords = TicketsController{}
// ListExtendRecords fills the owner e-mails of one list page with one
// query. The framework calls it once per page, before the columns are read.
func (TicketsController) ListExtendRecords(ctx context.Context, db *gorm.DB, records []any) error {
ids := make([]uint, 0, len(records))
for _, record := range records {
ids = append(ids, record.(*Ticket).OwnerID)
}
var owners []struct {
ID uint
Email string
}
if err := db.WithContext(ctx).Table("acme_owners").Select("id, email").Where("id IN ?", ids).Scan(&owners).Error; err != nil {
return err
}
emails := make(map[uint]string, len(owners))
for _, owner := range owners {
emails[owner.ID] = owner.Email
}
for _, record := range records {
ticket := record.(*Ticket)
ticket.OwnerEmail = emails[ticket.OwnerID]
}
return nil
}

View File

@@ -218,6 +218,7 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
}
modelCols, modelRels, checkModel := listModelContract(ctl)
unstored := unstoredModelColumns(ctl)
compiled := make([]ListColumn, 0, len(cols.Columns))
seen := map[string]struct{}{}
for _, item := range cols.Columns {
@@ -280,6 +281,14 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
if spec.Sortable != nil {
sortable = *spec.Sortable
}
// A column of a field gorm ignores (filled by pact.ListExtendRecords)
// has no database column: it is never sorted or searched.
if _, ok := unstored[key]; ok && spec.Relation == "" {
if sortable && spec.Sortable != nil || spec.Searchable {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s is not stored: it cannot be sortable or searchable", key))
}
sortable = false
}
compiled = append(compiled, ListColumn{
Key: key,
Label: spec.Label,
@@ -538,6 +547,43 @@ func listModelContract(ctl pact.AdminController) (map[string]string, map[string]
return cols, rels, true
}
// unstoredModelColumns names the model fields gorm ignores (a "-" tag), by
// their column tag or field name: values a controller fills through
// pact.ListExtendRecords.
func unstoredModelColumns(ctl pact.AdminController) map[string]struct{} {
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
model := src.NewRecord()
if model == nil {
return nil
}
out := map[string]struct{}{}
for _, mf := range modelFields(reflect.TypeOf(model)) {
if !gormIgnored(mf.Field) {
continue
}
name := gormColumn(mf.Field)
if name == "" {
name = mf.Field.Name
}
out[name] = struct{}{}
}
return out
}
// gormIgnored reports a gorm "-" (or "-:all") tag.
func gormIgnored(field reflect.StructField) bool {
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
switch strings.TrimSpace(part) {
case "-", "-:all":
return true
}
}
return false
}
// listColumnKind is the Winter list type of a model field when columns.yaml
// omits type: datetime for time.Time, date for lagoon.Date, time for
// lagoon.TimeOfDay. Everything else stays text (the admin's default).

View File

@@ -645,3 +645,33 @@ func filterCatalog(t *testing.T) *phrasebook.Catalog {
}
return cat
}
// TestListUnstoredColumn: a column of a field gorm ignores (filled by
// pact.ListExtendRecords) compiles unsortable, and declaring it sortable or
// searchable fails the boot.
func TestListUnstoredColumn(t *testing.T) {
type row struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Badge string `gorm:"-:all;column:badge"`
Note string `gorm:"-"`
}
ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &row{}}
schema, err := CompileList("acme.demo", ctl, listFS(allColumnsConfig, "columns:\n name:\n label: Name\n badge:\n label: Badge\n Note:\n label: Note\n"))
if err != nil {
t.Fatal(err)
}
sortable := map[string]bool{}
for _, col := range schema.Columns {
sortable[col.Key] = col.Sortable
}
if !sortable["name"] || sortable["badge"] || sortable["Note"] {
t.Fatalf("sortable = %v, want only name", sortable)
}
for _, bad := range []string{"sortable: true", "searchable: true"} {
_, err := CompileList("acme.demo", ctl, listFS(allColumnsConfig, "columns:\n badge:\n label: Badge\n "+bad+"\n"))
if err == nil || !strings.Contains(err.Error(), "column badge is not stored") {
t.Fatalf("%s: err = %v", bad, err)
}
}
}

View File

@@ -56,6 +56,9 @@ type rosterPerson struct {
// whose contract sets WritableForeignKey, writes it.
OrganisationID *uint `gorm:"column:organisation_id"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
// Badge is not stored: ListExtendRecords fills it for the list's badge
// column, once per page.
Badge string `gorm:"-:all;column:badge" json:"-"`
}
func (rosterPerson) TableName() string { return "roster_people" }
@@ -123,6 +126,8 @@ type rosterSpy struct {
record []pact.AdminRecordActionInput
// states counts ListRowStates calls and keeps the size of each page.
states []int
// extended keeps the size of each page ListExtendRecords completed.
extended []int
// virtual keeps what each Form hook read from the context.
virtual []rosterVirtual
}
@@ -156,6 +161,14 @@ func (s *rosterSpy) recordStates(n int) {
s.states = append(s.states, n)
}
func (s *rosterSpy) takeExtended() []int {
s.mu.Lock()
defer s.mu.Unlock()
out := s.extended
s.extended = nil
return out
}
func (s *rosterSpy) takeStates() []int {
s.mu.Lock()
defer s.mu.Unlock()
@@ -218,6 +231,10 @@ const (
rosterShort = "Short"
// rosterStateErr: ListRowStates fails with a plain error.
rosterStateErr = "StateErr"
// rosterExtendErr: ListExtendRecords fails with a plain error.
rosterExtendErr = "ExtendErr"
// rosterTitleErr: FormRecordTitle fails with a plain error.
rosterTitleErr = "TitleErr"
// rosterAppliesErr: the activate record action's Applies fails.
rosterAppliesErr = "AppliesErr"
// rosterCrash: the archive bulk action fails with a plain error.
@@ -413,6 +430,41 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
return out, nil
}
// ListExtendRecords fills the unstored badge of one list page in a single
// pass: "<initial>#<id>", and "deleted" for a soft-deleted person.
func (c rosterController) ListExtendRecords(ctx context.Context, db *gorm.DB, records []any) error {
c.spy.mu.Lock()
c.spy.extended = append(c.spy.extended, len(records))
c.spy.mu.Unlock()
if _, inTx := cabana.TxFromContext(ctx); inTx || db == nil {
return fmt.Errorf("a list hook gets the list handle, not a transaction")
}
for _, record := range records {
person := record.(*rosterPerson)
if person.Name == rosterExtendErr {
return fmt.Errorf("the badge table said hunter2")
}
person.Badge = fmt.Sprintf("%.1s#%d", person.Name, person.ID)
if person.DeletedAt.Valid {
person.Badge += " deleted"
}
}
return nil
}
// FormRecordTitle names a person "Person #<id> (<name>)" on the form and
// preview screens; a person named rosterTitleErr fails the request.
func (rosterController) FormRecordTitle(_ context.Context, db *gorm.DB, record any) (string, error) {
person := record.(*rosterPerson)
if db == nil {
return "", fmt.Errorf("no database handle")
}
if person.Name == rosterTitleErr {
return "", fmt.Errorf("the title table said hunter2")
}
return fmt.Sprintf("Person #%d (%s)", person.ID, person.Name), nil
}
// rosterStatus is the curated view model of the preview status hint: the
// callout tone and the phrase keys of its title and text. It is empty when
// no state applies, and the template then renders nothing.

View File

@@ -0,0 +1,63 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
)
// TestListExtendRecords: the list hook completes each page once, before the
// columns are read, so an unstored field's column shows the value it set; a
// hook error fails the list with the generic 500 and no error text.
func TestListExtendRecords(t *testing.T) {
env, gdb := newRosterEnv(t)
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
gone := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", DeletedAt: rosterDeleted()})
body, raw := rosterList(t, env, "")
badges := map[string]any{}
for _, row := range body.Data {
badges[fmt.Sprint(row["id"])] = row["badge"]
}
if badges[fmt.Sprint(ada)] != fmt.Sprintf("A#%d", ada) || badges[fmt.Sprint(gone)] != fmt.Sprintf("B#%d deleted", gone) {
t.Fatalf("badges = %v\n%s", badges, raw)
}
if got := env.spy.takeExtended(); len(got) != 1 || got[0] != 2 {
t.Fatalf("ListExtendRecords pages = %v, want one page of 2", got)
}
// The unstored column is not sortable.
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, rosterPeople+"?sort=badge", "", "bearer")
logs := captureLog(t)
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterExtendErr})
rec := env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople, "", "bearer")
if got := rosterError(t, rec); got.Code != "error" || strings.Contains(rec.Body.String(), "hunter2") {
t.Fatalf("500 body = %s", rec.Body.String())
}
if failed := logs.matching("cabana: list extend records failed"); len(failed) != 1 || !strings.Contains(failed[0], "controller=acme.roster.people") {
t.Fatalf("server log = %q", failed)
}
}
// TestFormRecordTitle: the show response carries the controller's record
// title as meta.title; a hook error fails the request without its text.
func TestFormRecordTitle(t *testing.T) {
env, gdb := newRosterEnv(t)
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"})
rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, ada), "", "bearer")
var body struct {
Meta map[string]json.RawMessage `json:"meta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if got := string(body.Meta["title"]); got != fmt.Sprintf("%q", fmt.Sprintf("Person #%d (Ada)", ada)) {
t.Fatalf("meta.title = %s\n%s", got, rec.Body.String())
}
bad := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterTitleErr})
rec = env.expect(t, http.StatusInternalServerError, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, bad), "", "bearer")
if strings.Contains(rec.Body.String(), "hunter2") {
t.Fatalf("500 body = %s", rec.Body.String())
}
}

View File

@@ -148,11 +148,18 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
return nil, err
}
values := slice.Elem()
data := make([]map[string]any, 0, values.Len())
records := make([]any, 0, values.Len())
for i := 0; i < values.Len(); i++ {
record := values.Index(i).Addr().Interface()
records = append(records, record)
records = append(records, values.Index(i).Addr().Interface())
}
if hook, ok := cc.Controller.(pact.ListExtendRecords); ok && hook != nil && len(records) > 0 {
if err := hook.ListExtendRecords(ctx, db.WithContext(ctx), records); err != nil {
slog.Error("cabana: list extend records failed", "controller", controllerID(cc), "error", err)
return nil, err
}
}
data := make([]map[string]any, 0, len(records))
for _, record := range records {
data = append(data, projectRowCtx(ctx, record, cc.Controller, cc.List.Columns))
}
states, err := listRowStates(ctx, db, cc, records)

View File

@@ -94,6 +94,9 @@ type RelationOption struct {
// create and update responses carry them, and the key is omitted when no type
// reported anything.
type RecordMeta struct {
// Title is the controller's record title (pact.FormRecordTitle), omitted
// when the controller names none.
Title string `json:"title,omitempty"`
Labels map[string][]RelationOption `json:"labels"`
Actions []RecordAction `json:"actions,omitempty"`
Notices []FieldNotice `json:"notices,omitempty"`

View File

@@ -25,6 +25,7 @@ people:
inactive_title: This person is not active
inactive_text: Activate the person to let them sign in.
slug: Slug
badge: Badge
password: Password
password_confirmation: Repeat the password
notify: Send a welcome message

View File

@@ -25,6 +25,7 @@ people:
inactive_title: Ta osoba jest nieaktywna
inactive_text: Aktywuj osobę, aby mogła się zalogować.
slug: Slug
badge: Odznaka
password: Hasło
password_confirmation: Powtórz hasło
notify: Wyślij wiadomość powitalną

View File

@@ -8,3 +8,5 @@ columns:
invisible: true
slug:
label: acme.roster::lang.people.slug
badge:
label: acme.roster::lang.people.badge

View File

@@ -29,7 +29,7 @@ Use cases:
- 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; a `Detached` action runs outside the framework transaction), `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), a hook after a reorder save (`pact.ReorderAfterSave`), 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`).
- 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`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), 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`).
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot.
- Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list.
@@ -148,6 +148,8 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `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.ListExtendRecords` | Optional controller hook called once per list page, before the columns are read; fills unstored fields of the page's records. |
| `pact.FormRecordTitle` | Optional controller hook naming a record on its form and preview screens; sent as the record envelope's `meta.title`. |
| `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. |

View File

@@ -734,6 +734,17 @@ type ListRowStates interface {
ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error)
}
// ListExtendRecords optionally completes the records of one admin list page
// before their columns are read (WinterCMS's listExtendRecords). The framework
// calls it once per page with the page's records (pointers, in page order) and
// the list's database handle, so a controller can fill non-persisted fields
// its columns show with one batched query instead of one per row. A column of
// such a field names it by its gorm column tag and sets sortable: false. An
// error fails the list.
type ListExtendRecords interface {
ListExtendRecords(ctx context.Context, db *gorm.DB, records []any) 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.
@@ -741,6 +752,16 @@ type ListRelationColumnMapper interface {
ListRelationColumn(relation, column string) (string, bool)
}
// FormRecordTitle optionally names a record on its form and preview screens.
// The framework calls it whenever it sends a record (the show, create and
// update responses) with the loaded record and the request's database handle.
// A non-empty answer is sent as the record envelope's meta.title, which the
// admin shows instead of the value of the form's first text field. An error
// fails the request.
type FormRecordTitle interface {
FormRecordTitle(ctx context.Context, db *gorm.DB, record any) (string, error)
}
// FormExtendQuery optionally narrows admin form record lookup.
type FormExtendQuery interface {
FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB