feat(32-02): record events from every admin write path
- Delete and each bulk-delete row fire RecordDeleted after commit - tree reorder fires RecordSaved per moved record (parent/sort in model types) - relation-manager child create/update/delete fire events naming the controller that edits the child's model - TestRecordEvents covers create, update, translated slug, no-op, rollback (validation, FormBeforeUpdate, extension Save), failing listener, reorder, child writes, delete and bulk delete; extension Save sees the changes - docs: Record events in extending.md, admin-controllers notes, cabana README
This commit is contained in:
@@ -222,6 +222,8 @@ A hook or scope that has to read the database during a write should use the tran
|
||||
|
||||
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
|
||||
|
||||
Other plugins do not implement these hooks to follow your records; they listen for the record events every admin write fires after its commit: `pact.RecordSaved` from create, update, tree reorder (one per moved record) and relation-manager child create and update, `pact.RecordDeleted` from delete, each row of a bulk delete and relation-manager child delete. Record and bulk actions write through your own code and fire nothing unless that code calls `cabana.EmitRecordSaved` or `cabana.EmitRecordDeleted`. See [Record events](../plugins/extending.md#record-events).
|
||||
|
||||
### Form-only values and rules
|
||||
|
||||
A form may collect values that are not columns of the record (see [Form-only fields](forms.md#form-only-fields)). The Form hooks read what the administrator submitted with `cabana.VirtualFieldsFromContext(ctx)`. The map holds only the fields that were sent and that the field's `context` allows for this operation, so a missing key means "not submitted". Values are scalars as decoded from the request: a string, a bool, a `json.Number` or nil. The map is a copy, and the second result is false outside a create or update.
|
||||
@@ -606,7 +608,7 @@ Every field and tab of a compiled form has a stable name: the field key, and the
|
||||
|
||||
An extension adds value fields (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` with listed options, `markdown`, `mltext`, `mltextarea`, `mlmarkdown` and plugin field types), `widget` fields and `partial` fields. Types that bind to the owner's model are refused at boot, `fileupload` included: an extension owns no attachments, so a plugin field type with `Upload` stores files instead.
|
||||
|
||||
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, and `Values`, the submitted values of the extension's fields whose `context` allows the operation. `Save` runs on every create and update, with or without values.
|
||||
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, `Values`, the submitted values of the extension's fields whose `context` allows the operation, and `Changes`, what the owner's save changed (old and new values, a translated value per locale), as the save's `pact.RecordSaved` will report it after the commit. `Save` runs on every create and update, with or without values.
|
||||
- A translatable field (`mltext`, `mltextarea`, `mlmarkdown`, a translatable plugin field type) reaches `Save` as a `map[string]string` of locale to text, and `Load` returns it the same way. A plugin field type's `Decode` runs before `Save` (its notices are kept, a `pact.FormFieldError` is a 422 on the field) and its `Load` after the extension's `Load`.
|
||||
- A `Save` error rolls the whole save back, the owner's row included. `pact.AdminFormErrors` (field name to messages, phrase keys or text) answers 422 with those lines on the fields; any other error is logged and answered with the generic 500 body. `pact.AdminFormSaveResult.Notices` are save notices per field, shown on the field like a field type's notices; a notice may name an owner field.
|
||||
- On show and in the save response, `Load` returns the values by field name; keys that are not the extension's fields are dropped.
|
||||
|
||||
@@ -44,6 +44,42 @@ Each listener names the plugin that owns it, so an error or a recovered panic in
|
||||
|
||||
`festival.Bus.ListenPriority` sets a priority: higher priorities run first, and equal priorities run in registration order.
|
||||
|
||||
### Record events
|
||||
|
||||
The framework fires two events for every record written through the admin: `pact.RecordSaved` after a create or update, `pact.RecordDeleted` after a delete. They are fired as pointers, so a listener takes `*pact.RecordSaved` or `*pact.RecordDeleted`.
|
||||
|
||||
A `pact.RecordSaved` carries the admin controller ID (`Controller`), the record's morph type (`Model`, the model's `MorphName`, else its table name), its primary key as text (`ID`), the request locale, `Created` and `Changes`: one `pact.RecordChange` per changed attribute with its old and new value. A translated value is reported once per locale, with `Locale` set, so a translated slug change is visible too. A save that changed nothing still fires, with no changes. A `pact.RecordDeleted` carries `Controller`, `Model` and `ID`.
|
||||
|
||||
The events fire after the write commits, never for a write that rolled back, and synchronously before the response is written. A listener's error is logged and never fails the write. Admin creates and updates, deletes and bulk deletes, tree reorders (a moved record reports its parent and sort columns) and relation-manager child writes all fire them; a child's event names the controller that edits the child's model. A form extension's `Save` receives the same changes in `pact.AdminFormSave.Changes`, inside the save transaction.
|
||||
|
||||
Code that writes records outside the admin (its own API handlers, imports, record actions) fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`. Pass the write's `lagoon.Transaction` handle and the event waits for its commit; pass nil after the write has committed and it fires at once:
|
||||
|
||||
```go src=modules/cabana/example_record_events_test.go#ExampleEmitRecordSaved
|
||||
app := backpack.New(nil) // in a plugin, the application it booted with
|
||||
|
||||
// acme.audit listens from its Boot step; record events arrive as pointers.
|
||||
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
|
||||
for _, change := range ev.Changes {
|
||||
fmt.Printf("%s %s: %s %q -> %q (locale %q)\n", ev.Model, ev.ID, change.Attribute, change.Old, change.New, change.Locale)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// acme.blog's own API handler has saved a post outside the admin forms.
|
||||
// The write has committed, so it passes no transaction and the event
|
||||
// fires at once; inside a lagoon.Transaction it would pass tx and the
|
||||
// event would wait for the commit.
|
||||
cabana.EmitRecordSaved(context.Background(), app, nil, pact.RecordSaved{
|
||||
Model: "acme_blog_posts",
|
||||
ID: "42",
|
||||
Changes: []pact.RecordChange{{Attribute: "slug", Old: "hello", New: "hello-world"}},
|
||||
})
|
||||
// Output:
|
||||
// acme_blog_posts 42: slug "hello" -> "hello-world" (locale "")
|
||||
```
|
||||
|
||||
Prefer these events to a GORM callback on another plugin's model (see [Model hooks and GORM callbacks](#model-hooks-and-gorm-callbacks)): a callback sees one statement, not the admin save it belongs to, cannot tell old translated values from new ones, and runs before the transaction commits.
|
||||
|
||||
## Services
|
||||
|
||||
A plugin that offers functionality to others publishes it on the container during Register with `backpack.App.Publish`, preferably under an interface type. Other plugins read it during Boot with `backpack.App.Lookup`. Because both sides use the same type, the consumer only imports the package that declares the interface, not the provider's internals. See [Application lifecycle](../architecture/application-lifecycle.md) for a complete example.
|
||||
|
||||
@@ -61,7 +61,7 @@ Use cases:
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
|
||||
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
|
||||
- Record events: every admin save fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them). A rolled-back save fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
|
||||
- Record events: every admin create and update fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them); a save that changed nothing fires with no changes. Delete and every row of a bulk delete fire `pact.RecordDeleted`; a tree reorder fires `pact.RecordSaved` for each moved record (parent and sort columns); relation-manager child writes fire both for the child, naming the controller that edits the child's model. A rolled-back write fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
|
||||
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. Only the My account revoke fires it: `admin:token:revoke` runs in its own process, where no MCP endpoint listens, so that token's sessions end when the token is next verified (the session's next request or its stream's periodic bearer re-check). A listener's error is logged and never fails the save.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
|
||||
@@ -325,6 +325,7 @@ func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any)
|
||||
if err := deleteRecord(ctx, tx, cc, target); err != nil {
|
||||
return err
|
||||
}
|
||||
emitRecordDeleted(ctx, s.app, tx, cc, target)
|
||||
result.Deleted = 1
|
||||
return nil
|
||||
})
|
||||
@@ -379,6 +380,7 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
|
||||
if err := deleteRecord(ctx, tx, cc, row); err != nil {
|
||||
return err
|
||||
}
|
||||
emitRecordDeleted(ctx, s.app, tx, cc, row)
|
||||
}
|
||||
result.Deleted = len(rows)
|
||||
return nil
|
||||
|
||||
34
modules/cabana/example_record_events_test.go
Normal file
34
modules/cabana/example_record_events_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
func ExampleEmitRecordSaved() {
|
||||
app := backpack.New(nil) // in a plugin, the application it booted with
|
||||
|
||||
// acme.audit listens from its Boot step; record events arrive as pointers.
|
||||
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
|
||||
for _, change := range ev.Changes {
|
||||
fmt.Printf("%s %s: %s %q -> %q (locale %q)\n", ev.Model, ev.ID, change.Attribute, change.Old, change.New, change.Locale)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// acme.blog's own API handler has saved a post outside the admin forms.
|
||||
// The write has committed, so it passes no transaction and the event
|
||||
// fires at once; inside a lagoon.Transaction it would pass tx and the
|
||||
// event would wait for the commit.
|
||||
cabana.EmitRecordSaved(context.Background(), app, nil, pact.RecordSaved{
|
||||
Model: "acme_blog_posts",
|
||||
ID: "42",
|
||||
Changes: []pact.RecordChange{{Attribute: "slug", Old: "hello", New: "hello-world"}},
|
||||
})
|
||||
// Output:
|
||||
// acme_blog_posts 42: slug "hello" -> "hello-world" (locale "")
|
||||
}
|
||||
@@ -775,7 +775,7 @@ func (s *service) relations() (RelationService, error) {
|
||||
if err != nil {
|
||||
return RelationService{}, err
|
||||
}
|
||||
svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}
|
||||
svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app, reg: s.reg}
|
||||
if s.app != nil {
|
||||
if w, ok := s.app.Lookup[TranslationWriter](); ok {
|
||||
svc.writer = w
|
||||
|
||||
@@ -3,6 +3,7 @@ package cabana
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -85,6 +86,106 @@ func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *
|
||||
})
|
||||
}
|
||||
|
||||
// controllerForRecord is the ID of the one admin controller whose records
|
||||
// have model's type, "" when none or several do. A nil registry knows none.
|
||||
func (r *Registry) controllerForRecord(model any) string {
|
||||
if r == nil || model == nil {
|
||||
return ""
|
||||
}
|
||||
want := reflect.TypeOf(model)
|
||||
found := ""
|
||||
for id, cc := range r.byID {
|
||||
src, ok := cc.Controller.(pact.AdminRecordSource)
|
||||
if !ok || src == nil {
|
||||
continue
|
||||
}
|
||||
record := src.NewRecord()
|
||||
if record == nil || reflect.TypeOf(record) != want {
|
||||
continue
|
||||
}
|
||||
if found != "" {
|
||||
return ""
|
||||
}
|
||||
found = id
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// reorderEvents are the RecordSaved events of a reorder save: one per node
|
||||
// whose parent or sort value changed, with the change in the model's column
|
||||
// types (a nil parent at the top).
|
||||
func reorderEvents(cc *CompiledController, morph string, locked []AdminReorderNode, nodes []AdminReorderNodeInput) []pact.RecordSaved {
|
||||
ro := cc.reorder
|
||||
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
|
||||
before := make(map[uint]AdminReorderNode, len(locked))
|
||||
for _, n := range locked {
|
||||
before[n.ID] = n
|
||||
}
|
||||
var out []pact.RecordSaved
|
||||
for _, node := range nodes {
|
||||
old, ok := before[uint(node.ID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var changes []pact.RecordChange
|
||||
if !ro.flat {
|
||||
var prev, next any
|
||||
if old.ParentID != nil {
|
||||
prev = columnValue(proto, ro.parentColumn, *old.ParentID)
|
||||
}
|
||||
if node.ParentID != nil {
|
||||
next = columnValue(proto, ro.parentColumn, uint(*node.ParentID))
|
||||
}
|
||||
if !sameChangeValue(prev, next) {
|
||||
changes = append(changes, pact.RecordChange{Attribute: ro.parentColumn, Old: prev, New: next})
|
||||
}
|
||||
}
|
||||
if old.SortOrder != node.SortOrder {
|
||||
changes = append(changes, pact.RecordChange{
|
||||
Attribute: ro.sortColumn,
|
||||
Old: columnValue(proto, ro.sortColumn, old.SortOrder),
|
||||
New: columnValue(proto, ro.sortColumn, node.SortOrder),
|
||||
})
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, pact.RecordSaved{
|
||||
Controller: controllerID(cc),
|
||||
Model: morph,
|
||||
ID: strconv.FormatUint(node.ID, 10),
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// columnValue converts v to the type model's column holds (a pointer
|
||||
// column's element type), so reorder changes match save changes. A column
|
||||
// the model does not have, or a type v does not convert to, keeps v.
|
||||
func columnValue(model any, column string, v any) any {
|
||||
rv := reflect.ValueOf(model)
|
||||
for rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return v
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
field := fieldByColumn(rv, column)
|
||||
if !field.IsValid() {
|
||||
return v
|
||||
}
|
||||
typ := field.Type()
|
||||
for typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
val := reflect.ValueOf(v)
|
||||
if !val.IsValid() || !val.Type().ConvertibleTo(typ) {
|
||||
return v
|
||||
}
|
||||
return val.Convert(typ).Interface()
|
||||
}
|
||||
|
||||
// recordMorphType is lagoon.MorphType, logged and "" when it fails.
|
||||
func recordMorphType(db *gorm.DB, model any) string {
|
||||
morph, err := lagoon.MorphType(db, model)
|
||||
|
||||
506
modules/cabana/record_events_postgres_test.go
Normal file
506
modules/cabana/record_events_postgres_test.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The D-92 record events on a real Postgres database: an acme.events plugin
|
||||
// with a pages controller (translatable slug, a tree reorder page and a
|
||||
// notes relation manager), a notes controller, and a contributor whose form
|
||||
// extension sees the save's changes.
|
||||
|
||||
const rePageMorph = `Acme\Events\Page`
|
||||
|
||||
type rePage struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
Notes []reNote `gorm:"-"`
|
||||
}
|
||||
|
||||
func (rePage) TableName() string { return "cabana_re_pages" }
|
||||
func (rePage) MorphName() string { return rePageMorph }
|
||||
func (*rePage) Fillable() []string { return []string{"title", "slug"} }
|
||||
func (*rePage) Rules() map[string]string { return map[string]string{"title": "required"} }
|
||||
|
||||
type reNote struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
PageID *uint `gorm:"column:page_id"`
|
||||
Body string `gorm:"column:body"`
|
||||
}
|
||||
|
||||
func (reNote) TableName() string { return "cabana_re_notes" }
|
||||
func (*reNote) Fillable() []string { return []string{"body"} }
|
||||
func (*reNote) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// rePages is the pages controller; failUpdate makes FormBeforeUpdate refuse.
|
||||
type rePages struct{ failUpdate *bool }
|
||||
|
||||
func (rePages) ID() string { return "acme.events.pages" }
|
||||
func (rePages) ModelName() string { return "Page" }
|
||||
func (rePages) ConfigDir() string { return "controllers/pages" }
|
||||
func (rePages) NewRecord() any { return &rePage{} }
|
||||
func (rePages) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB { return q }
|
||||
func (c rePages) FormBeforeUpdate(context.Context, any) error {
|
||||
if c.failUpdate != nil && *c.failUpdate {
|
||||
return errors.New("refused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (rePages) AdminRelationContracts() []RelationContract {
|
||||
return []RelationContract{{
|
||||
Name: "notes",
|
||||
Kind: RelationHasMany,
|
||||
NewRelated: func() any { return &reNote{} },
|
||||
ForeignKey: "page_id",
|
||||
Columns: map[string]string{"body": "body"},
|
||||
}}
|
||||
}
|
||||
|
||||
type reNotes struct{}
|
||||
|
||||
func (reNotes) ID() string { return "acme.events.notes" }
|
||||
func (reNotes) ModelName() string { return "Note" }
|
||||
func (reNotes) ConfigDir() string { return "controllers/notes" }
|
||||
func (reNotes) NewRecord() any { return &reNote{} }
|
||||
|
||||
type rePlugin struct{ failUpdate *bool }
|
||||
|
||||
func (rePlugin) ID() string { return "acme.events" }
|
||||
func (rePlugin) Requires() []string { return nil }
|
||||
func (rePlugin) Register(*backpack.App) error { return nil }
|
||||
func (rePlugin) Boot(*backpack.App) error { return nil }
|
||||
func (rePlugin) AdminFS() fs.FS { return reFS() }
|
||||
func (p rePlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{rePages{failUpdate: p.failUpdate}, reNotes{}}
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.FormBeforeUpdate = rePages{}
|
||||
_ AdminRelationContractProvider = rePages{}
|
||||
)
|
||||
|
||||
func reFS() fstest.MapFS {
|
||||
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
||||
return fstest.MapFS{
|
||||
"controllers/pages/config_form.yaml": file("name: Page\nform: ~/plugins/acme/events/models/page/fields.yaml\nmodelClass: Page\n"),
|
||||
"controllers/pages/config_list.yaml": file("list: ~/plugins/acme/events/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n"),
|
||||
"controllers/pages/config_reorder.yaml": file("title: Reorder pages\nnameFrom: title\nparentColumn: parent_id\nsortColumn: sort_order\n"),
|
||||
"controllers/pages/config_relation.yaml": file("notes:\n label: Notes\n view:\n list:\n columns:\n body:\n label: Body\n toolbarButtons: create|update|delete\n manage:\n form: $/acme/events/models/note/fields.yaml\n"),
|
||||
"models/page/columns.yaml": file("columns:\n title:\n label: Title\n"),
|
||||
"models/page/fields.yaml": file("fields:\n title:\n type: text\n label: Title\n slug:\n type: mltext\n label: Slug\n notes:\n type: relation-manager\n relation: notes\n"),
|
||||
"controllers/notes/config_form.yaml": file("name: Note\nform: ~/plugins/acme/events/models/note/fields.yaml\nmodelClass: Note\n"),
|
||||
"controllers/notes/config_list.yaml": file("list: ~/plugins/acme/events/models/note/columns.yaml\nmodelClass: Note\nrecordsPerPage: 20\n"),
|
||||
"models/note/columns.yaml": file("columns:\n body:\n label: Body\n"),
|
||||
"models/note/fields.yaml": file("fields:\n body:\n type: text\n label: Body\n"),
|
||||
}
|
||||
}
|
||||
|
||||
// reWriter stores translations in memory per record, field and locale.
|
||||
type reWriter struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func reKey(model any, field, locale string) string {
|
||||
return fmt.Sprintf("%d/%s/%s", pkUint(model), field, locale)
|
||||
}
|
||||
|
||||
func (*reWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
|
||||
func (*reWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||
return []string{"en", "pl"}, nil
|
||||
}
|
||||
func (w *reWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.data == nil {
|
||||
w.data = map[string]string{}
|
||||
}
|
||||
w.data[reKey(model, field, locale)] = value
|
||||
return nil
|
||||
}
|
||||
func (w *reWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
text, ok := w.data[reKey(model, field, locale)]
|
||||
return text, ok, nil
|
||||
}
|
||||
|
||||
// reListener records every record event the bus delivers.
|
||||
type reListener struct {
|
||||
mu sync.Mutex
|
||||
saved []pact.RecordSaved
|
||||
deleted []pact.RecordDeleted
|
||||
}
|
||||
|
||||
func (l *reListener) take() ([]pact.RecordSaved, []pact.RecordDeleted) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
saved, deleted := l.saved, l.deleted
|
||||
l.saved, l.deleted = nil, nil
|
||||
return saved, deleted
|
||||
}
|
||||
|
||||
type reEnv struct {
|
||||
db *gorm.DB
|
||||
svc *service
|
||||
listener *reListener
|
||||
extFail *error
|
||||
extSaves *[]pact.AdminFormSave
|
||||
}
|
||||
|
||||
func newREEnv(t *testing.T, failUpdate *bool) reEnv {
|
||||
t.Helper()
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&rePage{}, &reNote{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&rePage{}, &reNote{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Migrator().DropTable(&rePage{}, &reNote{}) })
|
||||
var (
|
||||
extFail error
|
||||
extSaves []pact.AdminFormSave
|
||||
)
|
||||
extra := feContributor{
|
||||
id: "acme.extra",
|
||||
fsys: feExtraFS("fields:\n extra:\n type: text\n label: Extra\n"),
|
||||
extensions: []pact.AdminActionExtension{{
|
||||
Controller: "acme.events.pages",
|
||||
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Save: func(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
||||
extSaves = append(extSaves, in)
|
||||
return pact.AdminFormSaveResult{}, extFail
|
||||
}},
|
||||
}},
|
||||
}
|
||||
reg, err := feCompile(rePlugin{failUpdate: failUpdate}, extra)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish[TranslationWriter](&reWriter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := &reListener{}
|
||||
app.Events.Listen("test.records", func(_ context.Context, ev *pact.RecordSaved) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.saved = append(l.saved, *ev)
|
||||
return nil
|
||||
})
|
||||
app.Events.Listen("test.records", func(_ context.Context, ev *pact.RecordDeleted) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.deleted = append(l.deleted, *ev)
|
||||
return nil
|
||||
})
|
||||
return reEnv{db: db, svc: &service{app: app, reg: reg}, listener: l, extFail: &extFail, extSaves: &extSaves}
|
||||
}
|
||||
|
||||
func (e reEnv) pages(t *testing.T) *CompiledController {
|
||||
t.Helper()
|
||||
cc, ok := e.svc.reg.Get("acme.events.pages")
|
||||
if !ok {
|
||||
t.Fatal("pages controller missing")
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
func (e reEnv) crud(t *testing.T) CRUDService {
|
||||
t.Helper()
|
||||
svc, err := e.svc.crud()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func (e reEnv) relations(t *testing.T) RelationService {
|
||||
t.Helper()
|
||||
svc, err := e.svc.relations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// reCtx is an administrator's request context with the content locale pl.
|
||||
func reCtx() context.Context {
|
||||
return towel.WithLocale(bouncer.WithUser(context.Background(), superUser()), "pl")
|
||||
}
|
||||
|
||||
func reID(t *testing.T, data map[string]any) uint {
|
||||
t.Helper()
|
||||
switch v := data["id"].(type) {
|
||||
case uint:
|
||||
return v
|
||||
case float64:
|
||||
return uint(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return uint(n)
|
||||
}
|
||||
n, err := strconv.ParseUint(fmt.Sprint(data["id"]), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("record id %v: %v", data["id"], err)
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
func oneSaved(t *testing.T, l *reListener) pact.RecordSaved {
|
||||
t.Helper()
|
||||
saved, deleted := l.take()
|
||||
if len(saved) != 1 || len(deleted) != 0 {
|
||||
t.Fatalf("events: saved %+v deleted %+v, want one RecordSaved", saved, deleted)
|
||||
}
|
||||
return saved[0]
|
||||
}
|
||||
|
||||
func noEvents(t *testing.T, l *reListener) {
|
||||
t.Helper()
|
||||
if saved, deleted := l.take(); len(saved) != 0 || len(deleted) != 0 {
|
||||
t.Fatalf("events after a refused write: saved %+v deleted %+v", saved, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordEvents (D-92): every admin write path fires RecordSaved or
|
||||
// RecordDeleted after the commit, with the changes per locale, and a
|
||||
// rolled-back write fires nothing.
|
||||
func TestRecordEvents(t *testing.T) {
|
||||
failUpdate := false
|
||||
env := newREEnv(t, &failUpdate)
|
||||
cc := env.pages(t)
|
||||
crud := env.crud(t)
|
||||
ctx := reCtx()
|
||||
var homeID uint
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
res, err := crud.Create(ctx, cc, RecordInput{Body: map[string]any{"title": "Home", "slug": map[string]any{"en": "home", "pl": "dom"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
homeID = reID(t, res)
|
||||
ev := oneSaved(t, env.listener)
|
||||
want := pact.RecordSaved{
|
||||
Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(homeID), 10), Locale: "pl", Created: true,
|
||||
Changes: []pact.RecordChange{
|
||||
{Attribute: "title", New: "Home"},
|
||||
{Attribute: "slug", New: "home"},
|
||||
{Attribute: "slug", Locale: "pl", New: "dom"},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(ev, want) {
|
||||
t.Fatalf("event = %+v\nwant %+v", ev, want)
|
||||
}
|
||||
if saves := *env.extSaves; len(saves) != 1 || !reflect.DeepEqual(saves[0].Changes, want.Changes) {
|
||||
t.Fatalf("extension Save changes = %+v, want the event's", saves)
|
||||
}
|
||||
})
|
||||
|
||||
update := func(body map[string]any) (map[string]any, error) {
|
||||
return crud.Update(ctx, cc, homeID, RecordInput{Body: body})
|
||||
}
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
*env.extSaves = nil
|
||||
if _, err := update(map[string]any{"title": "Start"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := oneSaved(t, env.listener)
|
||||
want := []pact.RecordChange{{Attribute: "title", Old: "Home", New: "Start"}}
|
||||
if ev.Created || !reflect.DeepEqual(ev.Changes, want) {
|
||||
t.Fatalf("event = %+v, want changes %+v", ev, want)
|
||||
}
|
||||
if saves := *env.extSaves; len(saves) != 1 || !reflect.DeepEqual(saves[0].Changes, want) {
|
||||
t.Fatalf("extension Save changes = %+v, want %+v", saves, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("translated slug", func(t *testing.T) {
|
||||
if _, err := update(map[string]any{"slug": map[string]any{"en": "home", "pl": "strona"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := oneSaved(t, env.listener)
|
||||
want := []pact.RecordChange{{Attribute: "slug", Locale: "pl", Old: "dom", New: "strona"}}
|
||||
if !reflect.DeepEqual(ev.Changes, want) {
|
||||
t.Fatalf("changes = %+v, want %+v", ev.Changes, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-op update", func(t *testing.T) {
|
||||
if _, err := update(map[string]any{"title": "Start", "slug": map[string]any{"en": "home", "pl": "strona"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := oneSaved(t, env.listener)
|
||||
if len(ev.Changes) != 0 || ev.ID != strconv.FormatUint(uint64(homeID), 10) {
|
||||
t.Fatalf("no-op event = %+v, want no changes", ev)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rollback", func(t *testing.T) {
|
||||
if _, err := update(map[string]any{"title": ""}); err == nil {
|
||||
t.Fatal("empty title saved")
|
||||
}
|
||||
noEvents(t, env.listener)
|
||||
failUpdate = true
|
||||
if _, err := update(map[string]any{"title": "Refused"}); err == nil {
|
||||
t.Fatal("FormBeforeUpdate refusal saved")
|
||||
}
|
||||
failUpdate = false
|
||||
noEvents(t, env.listener)
|
||||
*env.extFail = pact.AdminFormErrors{"extra": {"bad"}}
|
||||
if _, err := update(map[string]any{"title": "Refused"}); err == nil {
|
||||
t.Fatal("extension Save refusal saved")
|
||||
}
|
||||
*env.extFail = nil
|
||||
noEvents(t, env.listener)
|
||||
var page rePage
|
||||
if err := env.db.First(&page, homeID).Error; err != nil || page.Title != "Start" {
|
||||
t.Fatalf("page after refusals = %+v err=%v", page, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failing listener", func(t *testing.T) {
|
||||
env.svc.app.Events.Listen("test.failing", func(context.Context, *pact.RecordSaved) error {
|
||||
return errors.New("listener down")
|
||||
})
|
||||
if _, err := update(map[string]any{"title": "Again"}); err != nil {
|
||||
t.Fatalf("save failed with a failing listener: %v", err)
|
||||
}
|
||||
ev := oneSaved(t, env.listener)
|
||||
if len(ev.Changes) != 1 || ev.Changes[0].New != "Again" {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reorder", func(t *testing.T) {
|
||||
child := rePage{Title: "Child", Slug: "child", SortOrder: 2}
|
||||
if err := env.db.Create(&child).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.db.Model(&rePage{}).Where("id = ?", homeID).Update("sort_order", 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"nodes": []map[string]any{
|
||||
{"id": homeID, "parent_id": nil, "sort_order": 1},
|
||||
{"id": child.ID, "parent_id": homeID, "sort_order": 1},
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "events")
|
||||
req.SetPathValue("controller", "pages")
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), superUser()))
|
||||
rec := httptest.NewRecorder()
|
||||
env.svc.reorderPost(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reorder status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ev := oneSaved(t, env.listener)
|
||||
want := pact.RecordSaved{
|
||||
Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(child.ID), 10),
|
||||
Changes: []pact.RecordChange{
|
||||
{Attribute: "parent_id", Old: nil, New: homeID},
|
||||
{Attribute: "sort_order", Old: 2, New: 1},
|
||||
},
|
||||
}
|
||||
ev.Locale = ""
|
||||
if !reflect.DeepEqual(ev, want) {
|
||||
t.Fatalf("reorder event = %+v\nwant %+v", ev, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("child write", func(t *testing.T) {
|
||||
rel := env.relations(t)
|
||||
created, err := rel.CreateChild(ctx, cc, "notes", homeID, RecordInput{Body: map[string]any{"body": "first"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noteID := reID(t, created.Data)
|
||||
ev := oneSaved(t, env.listener)
|
||||
if ev.Controller != "acme.events.notes" || ev.Model != "cabana_re_notes" || !ev.Created || ev.ID != strconv.FormatUint(uint64(noteID), 10) {
|
||||
t.Fatalf("child create event = %+v", ev)
|
||||
}
|
||||
if _, err := rel.UpdateChild(ctx, cc, "notes", homeID, noteID, RecordInput{Body: map[string]any{"body": "second"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev = oneSaved(t, env.listener)
|
||||
want := []pact.RecordChange{{Attribute: "body", Old: "first", New: "second"}}
|
||||
if ev.Created || !reflect.DeepEqual(ev.Changes, want) {
|
||||
t.Fatalf("child update event = %+v, want changes %+v", ev, want)
|
||||
}
|
||||
if _, err := rel.DeleteChildren(ctx, cc, "notes", homeID, BulkDeleteInput{IDs: []any{float64(noteID)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, deleted := env.listener.take()
|
||||
wantDel := []pact.RecordDeleted{{Controller: "acme.events.notes", Model: "cabana_re_notes", ID: strconv.FormatUint(uint64(noteID), 10)}}
|
||||
if len(saved) != 0 || !reflect.DeepEqual(deleted, wantDel) {
|
||||
t.Fatalf("child delete events: saved %+v deleted %+v", saved, deleted)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
victim := rePage{Title: "Victim", Slug: "victim"}
|
||||
if err := env.db.Create(&victim).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := crud.Delete(ctx, cc, victim.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, deleted := env.listener.take()
|
||||
want := []pact.RecordDeleted{{Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(victim.ID), 10)}}
|
||||
if len(saved) != 0 || !reflect.DeepEqual(deleted, want) {
|
||||
t.Fatalf("delete events: saved %+v deleted %+v", saved, deleted)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulk delete", func(t *testing.T) {
|
||||
a, b := rePage{Title: "A", Slug: "a"}, rePage{Title: "B", Slug: "b"}
|
||||
for _, p := range []*rePage{&a, &b} {
|
||||
if err := env.db.Create(p).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := crud.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{float64(a.ID), float64(999999)}}); err == nil {
|
||||
t.Fatal("partial selection deleted")
|
||||
}
|
||||
noEvents(t, env.listener)
|
||||
if _, err := crud.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{float64(a.ID), float64(b.ID)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, deleted := env.listener.take()
|
||||
if len(saved) != 0 || len(deleted) != 2 {
|
||||
t.Fatalf("bulk delete events: saved %+v deleted %+v", saved, deleted)
|
||||
}
|
||||
got := map[string]bool{deleted[0].ID: true, deleted[1].ID: true}
|
||||
if !got[strconv.FormatUint(uint64(a.ID), 10)] || !got[strconv.FormatUint(uint64(b.ID), 10)] || deleted[0].Model != rePageMorph {
|
||||
t.Fatalf("bulk delete events = %+v", deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
@@ -200,6 +201,10 @@ type RelationService struct {
|
||||
bucket *blob.Bucket
|
||||
tr *phrasebook.Translator
|
||||
writer TranslationWriter
|
||||
// app receives the record events of child writes; reg names the
|
||||
// controller that edits a child's model. Both may be nil.
|
||||
app *backpack.App
|
||||
reg *Registry
|
||||
}
|
||||
|
||||
func (s RelationSchema) MarshalJSON() ([]byte, error) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -192,6 +193,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
||||
if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := recordChanges(ctx, tx, s.writer, child, nil, translations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case parent.unsaved():
|
||||
// The child exists now, unattached; the parent's first save
|
||||
@@ -213,6 +218,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
s.emitChildSaved(ctx, tx, child, true, changes)
|
||||
result, err = projectFullRecord(ctx, tx, cr.child, child)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -342,6 +348,10 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := takeRecordSnapshot(ctx, tx, s.writer, child, translations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -356,6 +366,10 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
|
||||
if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := recordChanges(ctx, tx, s.writer, child, before, translations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.commitChildFiles(ctx, tx, cr, child, "update", in); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -364,6 +378,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
s.emitChildSaved(ctx, tx, child, false, changes)
|
||||
result, err = projectFullRecord(ctx, tx, cr.child, child)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -455,9 +470,34 @@ func (s RelationService) deleteChild(ctx context.Context, tx *gorm.DB, cc *Compi
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if s.app != nil && s.app.Events != nil {
|
||||
EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{
|
||||
Controller: s.reg.controllerForRecord(child),
|
||||
Model: recordMorphType(tx, child),
|
||||
ID: recordIDText(child),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitChildSaved fires the RecordSaved of a child a relation manager wrote,
|
||||
// after tx commits. Controller is the controller that edits the child's
|
||||
// model, "" when no single one does.
|
||||
func (s RelationService) emitChildSaved(ctx context.Context, tx *gorm.DB, child any, created bool, changes []pact.RecordChange) {
|
||||
if s.app == nil || s.app.Events == nil {
|
||||
return
|
||||
}
|
||||
locale, _ := towel.Locale(ctx)
|
||||
EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{
|
||||
Controller: s.reg.controllerForRecord(child),
|
||||
Model: recordMorphType(tx, child),
|
||||
ID: recordIDText(child),
|
||||
Locale: locale,
|
||||
Created: created,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
|
||||
// loadPivotRow loads this parent's pivot row for the related id, locked; a
|
||||
// missing row is recordNotFound.
|
||||
func loadPivotRow(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (any, error) {
|
||||
|
||||
@@ -332,6 +332,14 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
|
||||
pk := primaryColumn(proto)
|
||||
// D-92: one RecordSaved per moved node, after the commit.
|
||||
if s.app != nil && s.app.Events != nil {
|
||||
morph := recordMorphType(tx, proto)
|
||||
for _, ev := range reorderEvents(cc, morph, locked.Nodes, in.Nodes) {
|
||||
ev.Locale, _ = towel.Locale(lctx)
|
||||
EmitRecordSaved(lctx, s.app, tx, ev)
|
||||
}
|
||||
}
|
||||
for _, node := range in.Nodes {
|
||||
var parent any
|
||||
if node.ParentID != nil {
|
||||
|
||||
Reference in New Issue
Block a user