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

@@ -2360,6 +2360,10 @@
"$ref": "#/components/schemas/cabana.FieldNotice"
},
"type": "array"
},
"title": {
"description": "Title is the controller's record title (pact.FormRecordTitle), omitted\nwhen the controller names none.",
"type": "string"
}
},
"required": [

View File

@@ -6158,6 +6158,11 @@ export interface components {
[key: string]: components["schemas"]["cabana.RelationOption"][];
};
notices?: components["schemas"]["cabana.FieldNotice"][];
/**
* @description Title is the controller's record title (pact.FormRecordTitle), omitted
* when the controller names none.
*/
title?: string;
};
"cabana.RelationColumn": {
key: string;

View File

@@ -279,6 +279,7 @@ messages:
- The screen shows every field whose `context` allows `preview`. A field without a `context` shows on every screen; `context: preview` shows a field only here. Values are rendered as text; `widget` and `relation-manager` fields are not shown.
- A field with `context: preview` is never written. A save asks only for the `create` or the `update` context, so such a field in a request body is dropped like any field hidden on that form.
- `messages.preview` is the screen's subtitle and `messages.edit` the label of its edit button; both default to framework texts (`cabana.FormMessages`).
- The heading of the preview and of the update form is the record's name: the value of the form's first `text` (or multilingual text) field. A controller that implements `pact.FormRecordTitle` names the record itself, for example "Subscription #12 (Pro)": the show, create and update responses then carry its answer as `meta.title` (`cabana.RecordMeta`), which the SPA shows instead. The hook gets the loaded record and the request's database handle; an error fails the request.
- `recordUrl` in `config_list.yaml` and the form's `create.redirect`, `update.redirectClose` and the other redirects may point at the screen as `<vendor>/<plugin>/<controller>/preview/:id`. On the update form of a record with a preview, the back arrow and Cancel return to the preview; after a delete the form goes to the list.
- The footer holds the record actions the show response offers in `meta.actions`, then the edit button. After an action the record and the status hint are loaded again in place.

View File

@@ -138,6 +138,67 @@ The choices of a scope filter are asked from the controller first, when it imple
WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method.
## Computed columns
A column can show a value that is not stored on the model, such as the e-mail of a record's owner kept in another table. The model declares the field with a gorm `-` tag and a column name, and the controller implements `pact.ListExtendRecords`, which the framework calls once per list page, after the records are loaded and before their columns are read, with the page's records and the list's database handle. One query fills the field for the whole page, as WinterCMS's `listExtendRecords` does:
```go src=modules/cabana/example_extendrecords_test.go
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
}
```
```yaml
columns:
owner_email:
label: acme.helpdesk::lang.tickets.owner_email
```
Such a column is never sorted or searched: it compiles unsortable, and `sortable: true` or `searchable: true` on it stops the start-up. An error from the hook fails the list.
## Row state
A list can mark rows with a state, as a WinterCMS list does with `listInjectRowClass`: a deleted record, a blocked account, an inactive one. The controller implements `pact.ListRowStates`:

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