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

@@ -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`: