fix(32): WR-02 report the request locale's primary tag on record events
Admin saves copied the raw context locale into RecordSaved.Locale, which could be a whole Accept-Language header, while reorder reported a normalised code. Saves, child writes and reorder now report the primary tag of the request locale. The pact doc says what Locale is and that per-locale work keys on RecordChange.Locale, since an admin save writes the row in the default content locale.
This commit is contained in:
@@ -48,7 +48,7 @@ Each listener names the plugin that owns it, so an error or a recovered panic in
|
||||
|
||||
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`.
|
||||
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 (`Locale`, the primary tag of the administrator's request locale in the admin; values written to the row are the default content locale's, so key per-locale work on the changes), `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.
|
||||
|
||||
|
||||
@@ -62,17 +62,24 @@ func emitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *Co
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
locale, _ := towel.Locale(ctx)
|
||||
EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
|
||||
Controller: controllerID(cc),
|
||||
Model: recordMorphType(tx, model),
|
||||
ID: recordIDText(model),
|
||||
Locale: locale,
|
||||
Locale: eventLocale(ctx),
|
||||
Created: created,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
|
||||
// eventLocale is the request locale an admin record event reports: the
|
||||
// primary tag of the context locale (a raw Accept-Language header becomes
|
||||
// its first tag), "" when the request has none.
|
||||
func eventLocale(ctx context.Context) string {
|
||||
locale, _ := towel.Locale(ctx)
|
||||
return primaryLanguage(locale)
|
||||
}
|
||||
|
||||
// emitRecordDeleted fires the admin delete of model through controller cc
|
||||
// after tx commits.
|
||||
func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// The D-92 record-event diff without a database: the snapshot taken before
|
||||
@@ -267,3 +268,22 @@ func TestRecordEventDiffValues(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRecordEventLocale (WR-02): an admin record event reports the primary
|
||||
// tag of the request locale, never a raw Accept-Language header.
|
||||
func TestRecordEventLocale(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"": "",
|
||||
"pl": "pl",
|
||||
"pl-PL,pl;q=0.9,en-US;q=0.8": "pl-PL",
|
||||
" en ; q=1": "en",
|
||||
} {
|
||||
ctx := context.Background()
|
||||
if in != "" {
|
||||
ctx = towel.WithLocale(ctx, in)
|
||||
}
|
||||
if got := eventLocale(ctx); got != want {
|
||||
t.Errorf("eventLocale(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
)
|
||||
@@ -487,12 +486,11 @@ func (s RelationService) emitChildSaved(ctx context.Context, tx *gorm.DB, child
|
||||
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,
|
||||
Locale: eventLocale(ctx),
|
||||
Created: created,
|
||||
Changes: changes,
|
||||
})
|
||||
|
||||
@@ -336,7 +336,7 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
ev.Locale = eventLocale(r.Context())
|
||||
EmitRecordSaved(lctx, s.app, tx, ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ Use cases:
|
||||
- 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. A record type may set `Content` to a `pact.ContentDescriptor`, which describes its records for consumers that work on every content type at once (sitemaps, search indexers, metadata editors): the admin controller that edits them, their morph type, `Describe` (a `pact.ContentRecord` with per-locale `pact.ContentLocale` URL, title, description, image and word count), and the optional `Records` (paged through `pact.ContentList` and `pact.ContentPage`) and `Match` (a public path to a `pact.ContentMatchResult`).
|
||||
- Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the content locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events.
|
||||
- Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the request locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events.
|
||||
- Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list.
|
||||
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
||||
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
|
||||
|
||||
@@ -29,8 +29,13 @@ type RecordChange struct {
|
||||
// for a write outside the admin forms. Model is the record's morph type
|
||||
// (lagoon.MorphType: the model's MorphName, else its table name), the same
|
||||
// value a content type declares in ContentDescriptor.Model. ID is the
|
||||
// primary key as text. Locale is the request's content locale, "" when it
|
||||
// has none. Created is true on create. Changes lists the attributes the save
|
||||
// primary key as text. Locale is the locale of the request that wrote the
|
||||
// record, "" when it has none; the admin reports the primary tag of the
|
||||
// administrator's request locale (the first tag of Accept-Language, or the
|
||||
// preferred locale). It does not say where values were written: an admin
|
||||
// save writes the record's row in the default content locale and reports
|
||||
// each translated value with its own RecordChange.Locale, so key per-locale
|
||||
// work on the changes. Created is true on create. Changes lists the attributes the save
|
||||
// changed with their old and new values; on create it lists every written
|
||||
// column with a nil Old. A save that changed nothing still fires, with an
|
||||
// empty Changes, so subscribers may re-check what they derive from the
|
||||
|
||||
Reference in New Issue
Block a user