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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
46
modules/cabana/example_extendrecords_test.go
Normal file
46
modules/cabana/example_extendrecords_test.go
Normal 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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
63
modules/cabana/phase181_records_test.go
Normal file
63
modules/cabana/phase181_records_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ą
|
||||
|
||||
@@ -8,3 +8,5 @@ columns:
|
||||
invisible: true
|
||||
slug:
|
||||
label: acme.roster::lang.people.slug
|
||||
badge:
|
||||
label: acme.roster::lang.people.badge
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user