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

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