feat(19.3-04): pact.ReorderAfterSave hook inside cabana's reorder transaction
- optional ReorderAfterSave(ctx, partition) runs after the new order is written; an error rolls the reorder back through lifecycleFailure - reorderPost runs in lagoon.Transaction so lagoon.AfterCommit work a hook registers (a site-cache purge) runs only after the commit - pact and cabana READMEs, admin hook table, tree reorder docs
This commit is contained in:
@@ -215,6 +215,7 @@ Behaviour overrides such as `formBeforeCreate` or `listExtendQuery` become optio
|
||||
| `pact.FormBeforeCreate`, `pact.FormAfterCreate` | Around a create, inside its transaction. |
|
||||
| `pact.FormBeforeUpdate`, `pact.FormAfterUpdate` | Around an update, inside its transaction. |
|
||||
| `pact.FormBeforeDelete`, `pact.FormAfterDelete` | Around a delete, inside its transaction. |
|
||||
| `pact.ReorderAfterSave` | After a reorder save wrote the new order, inside its transaction, with the request's partition (nil without one). An error rolls the reorder back. See [Tree reorder](lists-and-filters.md#tree-reorder). |
|
||||
| `pact.DropdownOptionsProvider` | Supplies the options of a `dropdown` field that names a method. |
|
||||
|
||||
A hook or scope that has to read the database during a write should use the transaction the write runs in, `cabana.TxFromContext(ctx)`, rather than the application pool: it then sees the write's own snapshot and takes no second connection while the transaction holds row locks. The handle is valid only until the hook returns, and the list route, which runs no transaction, reports none.
|
||||
|
||||
@@ -223,6 +223,8 @@ A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../re
|
||||
- A duplicate id, a parent outside the set, a cycle and a node deeper than `maxDepth` are 422 `validation_failed` on `nodes`; a partitioned page without `partition` is a 422 on `partition`.
|
||||
- Otherwise the parent and sort columns of every node are written, without running the model's save hooks, and the answer is the new tree.
|
||||
|
||||
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
|
||||
|
||||
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).
|
||||
|
||||
In the admin, a list whose schema carries `reorder` shows a "Reorder records" button before "New record". It opens the reorder page, and when the list is filtered on the partition column it opens that partition. The page shows the tree with a drag handle and move up, move down, outdent and indent buttons on every row. A row can also be dragged before, after or into another row (never into its own subtree), and the arrow keys on a focused handle move it the same way as the buttons. There is no Save button: 400 ms after the last move the page posts the whole set, shows "Order saved", and sends a pending save at once when the administrator leaves. Only one save is in flight at a time: moves made while it runs are sent after it settles, with the tree as it is then, so the stored order never ends older than the screen. A refused save puts the last saved order back, unless a later save is waiting (it carries the whole tree); a 409 reloads the tree. A save that settles after the page switched partition does not touch the new partition's tree. A partitioned page has a selector for the partition.
|
||||
|
||||
@@ -156,7 +156,7 @@ func (c articlesController) FormAfterUpdate(ctx context.Context, model any) erro
|
||||
}
|
||||
```
|
||||
|
||||
A nil purger (no purge configured) does nothing, so the hooks need no configuration check.
|
||||
A nil purger (no purge configured) does nothing, so the hooks need no configuration check. A tree or sortable list's reorder save runs `pact.ReorderAfterSave` the same way; see [Tree reorder](../backend/lists-and-filters.md#tree-reorder).
|
||||
|
||||
### The site side
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Use cases:
|
||||
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
|
||||
- Rich text: `type: richeditor` edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus `size`; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through [seaglass](../seaglass/README.md) before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in `meta.notices` under the field (`backend::lang.richeditor.removed_elements`, `removed_attributes`, `removed_comments` with the count and names, or `markup_changed`).
|
||||
- Repeater: `type: repeater` edits a JSON list in one column (`lagoon.Jsonable`) with a nested `form.fields` of `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn` a field of the same row) and `richeditor` fields, plus `prompt`, `minItems`, `maxItems` and `titleFrom` (`cabana.RepeaterForm` in the schema; the span is always full). Other nested types, a nested repeater, nested `tab`, `context` or `preset`, and WinterCMS's `groups` and `style` fail boot; settings and relation forms refuse the type. A save decodes the rows before the projection: null is an empty list, anything but a list of objects is a 422 on the field, each row keeps only the nested names with scalars coerced per type, a required nested value missing in a row is a 422 under `field.index.nested`, the row bounds are 422s on the field (`repeater.max_items`, `repeater.min_items`), nested richeditor values are sanitized with notices under the row path, and nested dependent dropdowns are rechecked. Record responses carry the list of rows.
|
||||
- Tree reorder (D-09): a `config_reorder.yaml` (`title`, `nameFrom`, `modelClass`, `parentColumn` default `parent_id`, `sortColumn` default `sort_order`, `maxDepth`, and an optional `partition` with `column`, `relation`, `nameFrom` and `label`) gives the controller a reorder page and the built-in `reorder` toolbar button; unknown keys, columns that are not record columns, a partition relation that is not a belongsTo relation field over the column, and a `reorder` button without the file fail boot. The list schema carries `reorder` (`cabana.ListReorder`). GET `.../reorder` answers the scoped tree (`cabana.AdminReorderTree`); POST `.../reorder` locks the scoped set (ListExtendQuery plus the partition) in one transaction, answers 409 `reorder_stale` when the posted ids differ from it in any way, 422 for a duplicate, a foreign parent, a cycle or a depth above `maxDepth`, and otherwise writes both tree columns without model hooks.
|
||||
- Tree reorder (D-09): a `config_reorder.yaml` (`title`, `nameFrom`, `modelClass`, `parentColumn` default `parent_id`, `sortColumn` default `sort_order`, `maxDepth`, and an optional `partition` with `column`, `relation`, `nameFrom` and `label`) gives the controller a reorder page and the built-in `reorder` toolbar button; unknown keys, columns that are not record columns, a partition relation that is not a belongsTo relation field over the column, and a `reorder` button without the file fail boot. The list schema carries `reorder` (`cabana.ListReorder`). GET `.../reorder` answers the scoped tree (`cabana.AdminReorderTree`); POST `.../reorder` locks the scoped set (ListExtendQuery plus the partition) in one transaction, answers 409 `reorder_stale` when the posted ids differ from it in any way, 422 for a duplicate, a foreign parent, a cycle or a depth above `maxDepth`, and otherwise writes both tree columns without model hooks, then calls the controller's optional `pact.ReorderAfterSave` with the request's partition inside the same `lagoon.Transaction` (an error rolls the reorder back; `lagoon.AfterCommit` work it registers runs after the commit).
|
||||
- Flat reorder (D-17): a `config_reorder.yaml` without `parentColumn` on a record that has no `parent_id` column (WinterCMS Sortable) compiles as a flat reorder: the list schema's `reorder` and the reorder routes' data carry `flat: true` (omitted on a tree, so tree payloads are unchanged), every node's `parent_id` is null, a save writes only the sort column, a node with a parent is a 422 on `nodes` (`backend::lang.reorder.flat_parent`), and the 409 stale-set and duplicate-id rules are unchanged. `maxDepth` above 0 on a flat reorder fails boot; an explicit `parentColumn` still means a tree.
|
||||
- Singleton controllers (D-11): a controller implementing `pact.AdminSingleton` needs only `config_form.yaml`; GET `.../singleton` answers the id of the record `SingletonRecord` returns (created when absent; a first request that loses the creation race on a unique index runs once more and finds the record) after checking the form scope, the form schema carries `singleton`, and the list, create, bulk and delete routes answer 404. A settings link entry may open it.
|
||||
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); `{field}` is `repeater.field` for a dropdown in a repeater row; the form schema carries `dependsOn`. Every save rechecks the value with the submitted sibling value (per row in a repeater): a value that does not apply is stored null, one outside the options is a 422 `form.dependent_invalid` on the field. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"gorm.io/gorm"
|
||||
@@ -293,7 +294,10 @@ func (s *service) reorderGet(w http.ResponseWriter, r *http.Request) {
|
||||
// requires the submitted ids to equal it exactly (409 reorder_stale
|
||||
// otherwise), refuses a duplicate id, a parent outside the set, a cycle and a
|
||||
// depth above maxDepth (422), then writes the parent and sort columns of
|
||||
// every node and answers the new tree.
|
||||
// every node, runs the controller's optional ReorderAfterSave hook (pact) with
|
||||
// the partition (its error rolls the reorder back) and answers the new tree.
|
||||
// The transaction is a lagoon.Transaction, so a hook can register work with
|
||||
// lagoon.AfterCommit that runs only once the new order is committed.
|
||||
func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if cc.reorder == nil || cc.singleton {
|
||||
@@ -312,8 +316,8 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
|
||||
var tree AdminReorderTree
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txCtx := withTx(ctx, tx)
|
||||
err = lagoon.Transaction(ctx, db, func(lctx context.Context, tx *gorm.DB) error {
|
||||
txCtx := withTx(lctx, tx)
|
||||
if cc.reorder.partition != nil && in.Partition == nil {
|
||||
return &ValidationError{Details: map[string]any{"partition": []string{"The partition field is required."}}}
|
||||
}
|
||||
@@ -347,6 +351,11 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if hook, ok := cc.Controller.(pact.ReorderAfterSave); ok && hook != nil {
|
||||
if err := lifecycleFailure(cc, hook.ReorderAfterSave(txCtx, in.Partition)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tree, err = s.reorderTree(txCtx, tx, cc, in.Partition, false)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -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), `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), 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`), 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.
|
||||
@@ -137,6 +137,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope. |
|
||||
| `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. |
|
||||
| `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. |
|
||||
| `pact.ReorderAfterSave` | Optional controller hook `ReorderAfterSave(ctx, partition)`: runs after a reorder save wrote the new order, inside the same transaction (`cabana.TxFromContext` works); an error rolls the reorder back. |
|
||||
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
||||
| `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. |
|
||||
| `pact.FormFieldType` | One field type: the alias `Name`, the custom-element tag `Element`, `Translatable`, the `Scripts` and `Styles` under `assets/`, the `Options` keys, and the funcs `CheckOptions` (optional), `Decode` (required), `Load`, `ListValue` (`func(ctx context.Context, value any) string`) and `Upload` (optional). |
|
||||
|
||||
@@ -765,6 +765,15 @@ type FormAfterDelete interface {
|
||||
FormAfterDelete(ctx context.Context, model any) error
|
||||
}
|
||||
|
||||
// ReorderAfterSave optionally runs after a reorder save (POST .../reorder of
|
||||
// a tree or sortable list) wrote the new order, inside the same transaction,
|
||||
// so cabana.TxFromContext returns it and lagoon.AfterCommit work waits for
|
||||
// the commit. partition is the reorder request's partition (nil when the
|
||||
// list has none). A returned error rolls the reorder back.
|
||||
type ReorderAfterSave interface {
|
||||
ReorderAfterSave(ctx context.Context, partition *uint64) error
|
||||
}
|
||||
|
||||
// FormVirtualFields optionally names fields of the controller's form that are
|
||||
// not model columns for the purpose of the form, such as a password and its
|
||||
// confirmation or a "send an invitation" checkbox. The admin framework never
|
||||
|
||||
Reference in New Issue
Block a user