diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 7941d6c..02a309f 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1376,6 +1376,24 @@ ], "type": "object" }, + "cabana.RecordAction": { + "properties": { + "confirm": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "label", + "name" + ], + "type": "object" + }, "cabana.RecordEnvelope": { "properties": { "data": { @@ -1393,6 +1411,12 @@ }, "cabana.RecordMeta": { "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/cabana.RecordAction" + }, + "type": "array" + }, "labels": { "additionalProperties": { "items": { @@ -3803,6 +3827,7 @@ ] }, "get": { + "description": "meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered.", "parameters": [ { "description": "Vendor", @@ -4014,6 +4039,140 @@ ] } }, + "/{vendor}/{plugin}/{controller}/{id}/actions/{action}": { + "post": { + "description": "Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty.", + "parameters": [ + { + "description": "Vendor", + "in": "path", + "name": "vendor", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plugin", + "in": "path", + "name": "plugin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Controller", + "in": "path", + "name": "controller", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Record id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Record action name", + "in": "path", + "name": "action", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminActionRequest" + } + } + }, + "description": "Empty object", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Run a declared record action", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}/files/{field}": { "get": { "description": "The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation.", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index e886528..596bee6 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1601,7 +1601,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Show an admin record */ + /** + * Show an admin record + * @description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered. + */ get: { parameters: { query?: never; @@ -1804,6 +1807,106 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/actions/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Run a declared record action + * @description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Record id */ + id: number; + /** @description Record action name */ + action: string; + }; + cookie?: never; + }; + /** @description Empty object */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminActionResult"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}/files/{field}": { parameters: { query?: never; @@ -4516,11 +4619,17 @@ export interface components { "cabana.PartialView": { nodes: components["schemas"]["cabana.PartialNode"][]; }; + "cabana.RecordAction": { + confirm?: string; + label: string; + name: string; + }; "cabana.RecordEnvelope": { data: components["schemas"]["cabana.AdminRecord"]; meta: components["schemas"]["cabana.RecordMeta"]; }; "cabana.RecordMeta": { + actions?: components["schemas"]["cabana.RecordAction"][]; labels: { [key: string]: components["schemas"]["cabana.RelationOption"][]; }; diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 718436c..2cfd058 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -36,6 +36,8 @@ export type RecordEnvelope = Schemas['cabana.RecordEnvelope'] export type BulkResult = Schemas['cabana.BulkResult'] /** A checkbox action of a list: the built-in delete or a declared bulk action. */ export type BulkAction = Schemas['cabana.BulkAction'] +/** A record action the record response offers: name, label and confirm text. */ +export type RecordAction = Schemas['cabana.RecordAction'] /** A declared bulk action's answer: the toast message and the affected count. */ export type BulkActionResult = Schemas['cabana.BulkActionResult'] /** Body of a widget action: the record id (absent on create) and fill values. */ diff --git a/admin/src/components/form/RecordActions.vue b/admin/src/components/form/RecordActions.vue new file mode 100644 index 0000000..2883d61 --- /dev/null +++ b/admin/src/components/form/RecordActions.vue @@ -0,0 +1,120 @@ + + + diff --git a/admin/tests/fixtures/roster.record.json b/admin/tests/fixtures/roster.record.json new file mode 100644 index 0000000..bb0e522 --- /dev/null +++ b/admin/tests/fixtures/roster.record.json @@ -0,0 +1,10 @@ +{ + "data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" }, + "meta": { + "labels": {}, + "actions": [ + { "name": "activate", "label": "Activate" }, + { "name": "reinstate", "label": "Reinstate", "confirm": "Lift the ban on this person?" } + ] + } +} diff --git a/admin/tests/fixtures/typed.ts b/admin/tests/fixtures/typed.ts index 5bc73be..cce975c 100644 --- a/admin/tests/fixtures/typed.ts +++ b/admin/tests/fixtures/typed.ts @@ -19,6 +19,7 @@ import listSchemaJson from './widgets.list-schema.json' import optionsJson from './widgets.options.json' import rosterListJson from './roster.list.json' import rosterListSchemaJson from './roster.list-schema.json' +import rosterRecordJson from './roster.record.json' import recordJson from './widgets.record.json' import candidatesJson from './widgets.relation-candidates.json' import linkedJson from './widgets.relation-linked.json' @@ -58,6 +59,8 @@ export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelop export const listFixture: Rows = listJson /** A people list with declared bulk actions (Phase 12.1). */ export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson +/** One person with two offered record actions (Phase 12.1). */ +export const rosterRecordFixture: S['cabana.RecordEnvelope'] = rosterRecordJson /** The people of the roster list (Phase 12.1). */ export const rosterListFixture: Rows = rosterListJson export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson diff --git a/admin/tests/smoke/actions.smoke.test.ts b/admin/tests/smoke/actions.smoke.test.ts index da45a26..927c1ed 100644 --- a/admin/tests/smoke/actions.smoke.test.ts +++ b/admin/tests/smoke/actions.smoke.test.ts @@ -1,11 +1,14 @@ // Phase 12.1 framework actions, SPA half: the bulk actions menu of a list -// (UI-SPEC S1, D-09). Fixtures are neutral acme.roster.* data; no application +// (UI-SPEC S1, D-09) and the record action buttons (UI-SPEC S2, D-10). +// Fixtures are neutral acme.roster.* data; no application // names appear in framework tests. import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' -import { clone, langFixture, rosterListFixture, rosterListSchemaFixture } from '../fixtures/typed' -import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' +import RecordActions from '../../src/components/form/RecordActions.vue' +import ToastHost from '../../src/components/ui/Toast.vue' +import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed' +import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' const LIST = `${API}/acme/roster/people` @@ -22,6 +25,11 @@ const strings = { }, 'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' }, 'backend::lang.extension.action_failed': { other: 'The action could not be completed. Please try again.' }, + 'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' }, + 'backend::lang.form.action_done': { other: 'Action completed.' }, + 'backend::lang.form.action_stale': { + other: 'This action no longer applies to this record. The page has been refreshed.', + }, } function routes(overrides: Record = {}): Record { @@ -315,3 +323,141 @@ describe('bulk actions menu (UI-SPEC S1, D-09)', () => { expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe(label) }) }) + +describe('record actions (UI-SPEC S2, D-10)', () => { + const RECORD = `${LIST}/1` + const source = { vendor: 'acme', plugin: 'roster', controller: 'people' } + const offered = rosterRecordFixture.meta.actions ?? [] + const done = (message: string): Reply => ({ body: { data: { message, fill: {} }, meta: {} } }) + + function mountActions(routes: Record, props: { actions?: typeof offered; disabled?: boolean } = {}) { + const calls = mockApi(routes) + const wrapper = mount( + { + components: { RecordActions, ToastHost }, + props: ['actions', 'disabled'], + emits: ['busy', 'done', 'stale', 'gone'], + template: `
+ + +
`, + setup: () => ({ source }), + }, + { props: { actions: props.actions ?? offered, disabled: props.disabled ?? false }, attachTo: document.body }, + ) + return { wrapper, calls } + } + + const button = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-record-action="${name}"]`) + + it('renders one outline button per offered action, in order, and nothing without actions', () => { + const { wrapper } = mountActions({}) + const buttons = wrapper.findAll('[data-record-action]') + expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['activate', 'reinstate']) + expect(buttons.map((item) => item.text())).toEqual(['Activate', 'Reinstate']) + expect(buttons.every((item) => item.classes().includes('border-border-strong') && item.classes().includes('whitespace-nowrap'))).toBe(true) + + const empty = mountActions({}, { actions: [] }) + expect(empty.wrapper.find('[data-record-action]').exists()).toBe(false) + }) + + it('confirms, posts {} to the action route and emits done with the server message', async () => { + const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('The person was activated.') }) + await button(wrapper, 'activate').trigger('click') + await flushPromises() + expect(dialog()!.textContent).toContain('Run “Activate” on this record?') + const confirm = dialog()!.querySelector('[data-action="confirm"]')! + expect(confirm.textContent?.trim()).toBe('Activate') + expect(confirm.classList.contains('bg-primary')).toBe(true) + await press('confirm') + + const [post] = requestsTo(calls, 'POST', `${RECORD}/actions/activate`) + expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') + expect(await post!.json()).toEqual({}) + expect(wrapper.emitted('done')).toEqual([['The person was activated.']]) + expect(wrapper.emitted('busy')).toEqual([[true], [false]]) + expect(dialog()).toBeNull() + }) + + it('uses the action confirm text and the default done text, and sends nothing on cancel', async () => { + const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/reinstate`]: done('') }) + await button(wrapper, 'reinstate').trigger('click') + await flushPromises() + expect(dialog()!.textContent).toContain('Lift the ban on this person?') + await press('cancel') + expect(requestsTo(calls, 'POST', `${RECORD}/actions/reinstate`)).toHaveLength(0) + expect(wrapper.emitted('done')).toBeUndefined() + + await button(wrapper, 'reinstate').trigger('click') + await flushPromises() + await press('confirm') + expect(wrapper.emitted('done')).toEqual([['Action completed.']]) + }) + + it('disables every button and keeps the dialog busy while an action runs', async () => { + let release: ((reply: Reply) => void) | undefined + const { wrapper } = mountActions({ + [`POST ${RECORD}/actions/activate`]: () => + new Promise((resolve) => { + release = resolve + }), + }) + await button(wrapper, 'activate').trigger('click') + await flushPromises() + await press('confirm') + expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true) + expect(dialog()!.querySelector('[data-action="confirm"]')!.getAttribute('aria-busy')).toBe('true') + expect(dialog()!.querySelector('[data-action="cancel"]')!.disabled).toBe(true) + expect(wrapper.emitted('busy')).toEqual([[true]]) + + release?.(done('')) + await flushPromises() + expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') === undefined)).toBe(true) + expect(dialog()).toBeNull() + }) + + it('emits stale with a toast on 409 and gone on 404', async () => { + const { wrapper } = mountActions({ + [`POST ${RECORD}/actions/activate`]: error(409, 'conflict', 'Conflict'), + [`POST ${RECORD}/actions/reinstate`]: error(404, 'not_found', 'Not found'), + }) + await button(wrapper, 'activate').trigger('click') + await flushPromises() + await press('confirm') + expect(wrapper.emitted('stale')).toHaveLength(1) + expect(wrapper.find('[data-tone="danger"]').text()).toContain('This action no longer applies to this record.') + + await button(wrapper, 'reinstate').trigger('click') + await flushPromises() + await press('confirm') + expect(wrapper.emitted('gone')).toHaveLength(1) + expect(wrapper.emitted('done')).toBeUndefined() + }) + + it('toasts a 403 and any other failure with the server message or the fallback', async () => { + const { wrapper } = mountActions({ + [`POST ${RECORD}/actions/activate`]: error(403, 'forbidden', ''), + [`POST ${RECORD}/actions/reinstate`]: error(500, 'error', 'The roster is locked.'), + }) + await button(wrapper, 'activate').trigger('click') + await flushPromises() + await press('confirm') + expect(wrapper.text()).toContain('You do not have permission to run this action.') + + await button(wrapper, 'reinstate').trigger('click') + await flushPromises() + await press('confirm') + expect(wrapper.text()).toContain('The roster is locked.') + expect(wrapper.emitted('done')).toBeUndefined() + expect(wrapper.emitted('stale')).toBeUndefined() + }) + + it('does nothing while the host disables it', async () => { + const { wrapper } = mountActions({}, { disabled: true }) + expect(button(wrapper, 'activate').attributes('disabled')).toBeDefined() + await button(wrapper, 'activate').trigger('click') + await flushPromises() + expect(dialog()).toBeNull() + }) +}) diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index dcffec8..faccb68 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -246,6 +246,7 @@ type Person struct { Name string `gorm:"column:name"` Email string `gorm:"column:email"` Active bool `gorm:"column:active"` + Banned bool `gorm:"column:banned"` } func (Person) TableName() string { return "acme_roster_people" } @@ -253,13 +254,14 @@ func (Person) TableName() string { return "acme_roster_people" } // Fillable lists the columns the admin form may write. func (Person) Fillable() []string { return []string{"name", "email"} } -// PeopleController is an admin controller whose list offers bulk actions. +// PeopleController is an admin controller with bulk and record actions. type PeopleController struct{} var ( - _ pact.AdminController = PeopleController{} - _ pact.AdminRecordSource = PeopleController{} - _ pact.HasAdminBulkActions = PeopleController{} + _ pact.AdminController = PeopleController{} + _ pact.AdminRecordSource = PeopleController{} + _ pact.HasAdminBulkActions = PeopleController{} + _ pact.HasAdminRecordActions = PeopleController{} ) func (PeopleController) ID() string { return "acme.roster.people" } @@ -318,6 +320,47 @@ func (PeopleController) AdminBulkActions() []pact.AdminBulkAction { }, }} } + +// AdminRecordActions registers the actions config_form.yaml offers under +// recordActions. Applies decides whether an action fits the record's current +// state; Run receives the record loaded and locked through the form scope. +func (PeopleController) AdminRecordActions() []pact.AdminRecordAction { + return []pact.AdminRecordAction{{ + Name: "activate", + Label: "acme.roster::lang.people.activate", + Permissions: []string{"acme.roster.manage"}, + Applies: func(_ context.Context, record any) (bool, error) { + return !record.(*Person).Active, nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("no transaction") + } + if err := tx.Model(in.Record).Update("active", true).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil + }, + }, { + Name: "reinstate", + Label: "acme.roster::lang.people.reinstate", + Confirm: "acme.roster::lang.people.reinstate_confirm", + Applies: func(_ context.Context, record any) (bool, error) { + return record.(*Person).Banned, nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("no transaction") + } + if err := tx.Model(in.Record).Update("banned", false).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{}, nil + }, + }} +} ``` ```yaml src=modules/cabana/testdata/roster/controllers/people/config_list.yaml @@ -345,3 +388,30 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se - `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`. Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button. + +## Record actions + +A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through `pact.HasAdminRecordActions` (`PeopleController` above registers two), and `recordActions` in `config_form.yaml` lists the ones the form offers, in display order: + +```yaml src=modules/cabana/testdata/roster/controllers/people/config_form.yaml +name: acme.roster::lang.people.form +form: ~/plugins/acme/roster/models/person/fields.yaml +modelClass: Person +defaultRedirect: acme/roster/people +create: + redirect: acme/roster/people/update/:id + redirectClose: acme/roster/people +update: + redirect: acme/roster/people + redirectClose: acme/roster/people +recordActions: [activate, reinstate] +``` + +Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`: + +- `Applies` reports whether the action fits the record's current state; without one the action always applies. It must only read, because it runs in two places: when a record is shown, to decide which actions to offer, and again inside the action's transaction, right before `Run`. +- The show response (`GET /{controller}/{id}`) lists the offered actions in `meta.actions` as `cabana.RecordAction` entries: only those the administrator may run and that apply to the record. The key is absent when none is offered, and create and update responses never carry it. +- `POST /{controller}/{id}/actions/{action}` takes an empty `{}` body. cabana loads the record through `pact.FormExtendQuery` with a row lock in one transaction and hands it to `Run` in `pact.AdminRecordActionInput`. A missing record and a record outside the scope are the same 404; an action whose `Applies` reports false answers 409 `conflict`; an administrator without the action's permissions gets 403. +- `Run` writes through `cabana.TxFromContext(ctx)` and returns a `pact.AdminRecordActionResult` with an optional `Message`. Any error rolls the transaction back. + +Record actions have their own namespace: a record action and a bulk action may share a name, such as `activate` here. `create` and `delete` are reserved. A name in `recordActions` that the controller does not register, a duplicate, or an action without a label stops the start-up. diff --git a/docs/backend/forms.md b/docs/backend/forms.md index 7e0539e..4940752 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -27,6 +27,8 @@ update: `modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins///` prefix points into the plugin's own embedded tree. +An optional `recordActions` key lists the record actions the form offers, by the names the controller registers through `pact.HasAdminRecordActions`. The show response of a record then carries `meta.actions`: the declared actions the administrator may run and that apply to the record in its current state. See [Record actions](admin-controllers.md#record-actions). + ## fields.yaml ```yaml src=modules/cabana/testdata/docs/models/post/fields.yaml diff --git a/modules/cabana/README.md b/modules/cabana/README.md index a5c6a83..3d6374f 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -18,6 +18,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count. +- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id. - Server-rendered partials: `headerPartial: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. @@ -44,6 +45,7 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). | | POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction; needs `delete` in `toolbar.buttons`. | | POST `/{vendor}/{plugin}/{controller}/bulk/{action}` | Run a declared bulk action on `{ids}` in one transaction; answers `{message, affected}`, 409 for a partial selection. | +| POST `/{vendor}/{plugin}/{controller}/{id}/actions/{action}` | Run a declared record action with an empty `{}` body in one transaction; answers `{message, fill: {}}`, 404 outside the form scope, 409 when the action does not apply. | | POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. | | POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. | | GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. | @@ -172,6 +174,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. | | `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. | | `cabana.AdminBulkAction` | Swag annotation of the bulk action route. | +| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. | +| `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. | +| `cabana.AdminRecordAction` | Swag annotation of the record action route. | | `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | | `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. | | `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go index 63c6aa4..b9ef00d 100644 --- a/modules/cabana/actions.go +++ b/modules/cabana/actions.go @@ -171,6 +171,72 @@ func bulkActionOf(cc *CompiledController, name string) (pact.AdminBulkAction, bo return action, ok } +// recordAction serves POST .../{controller}/{id}/actions/{action} (D-10): a +// registered record action the form's recordActions declares. The record is +// loaded through the controller's form scope with a row lock inside the +// action's transaction, so a missing or out-of-scope id is one 404, and an +// action that no longer applies to the record is a 409. +func (s *service) recordAction(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + name := r.PathValue("action") + action, ok := recordActionOf(cc, name) + if !ok { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + if !s.allowAction(w, r, action.Permissions) { + return + } + id, err := pathID(r) + if err != nil { + writeCRUDError(w, err) + return + } + in, err := decodeActionRequest(r) + if err != nil { + writeCRUDError(w, err) + return + } + if in.RecordID != nil || in.Values != nil { + writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id or values."}}}) + return + } + svc, err := s.crud() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + result, err := svc.RecordAction(r.Context(), cc, id, name) + if err != nil { + writeCRUDError(w, err) + return + } + var adminID uint + if principal, _ := bouncer.User(r.Context()); principal != nil { + adminID = principal.ID + } + slog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "record_id", id) + WriteData(w, http.StatusOK, result, nil) + }) +} + +// recordActionOf returns the registered record action a form declares under +// name in recordActions. +func recordActionOf(cc *CompiledController, name string) (pact.AdminRecordAction, bool) { + if cc == nil || cc.Form == nil || builtinToolbarActions[name] { + return pact.AdminRecordAction{}, false + } + declared := false + for _, entry := range cc.Form.recordActions { + declared = declared || entry == name + } + if !declared { + return pact.AdminRecordAction{}, false + } + action, ok := cc.RecordActions[name] + return action, ok +} + // allowAction applies an action's own permissions on top of the controller's // (already checked by protect). Every action kind shares it: a denial is // logged and answered 403. diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index cbdfb9a..725e9ee 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -418,6 +418,29 @@ func AdminBulkDelete() {} // @Router /{vendor}/{plugin}/{controller}/bulk/{action} [post] func AdminBulkAction() {} +// AdminRecordAction documents the declared record action route. +// +// @Summary Run a declared record action +// @Description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Record id" +// @Param action path string true "Record action name" +// @Param body body AdminActionRequest true "Empty object" +// @Success 200 {object} Envelope[AdminActionResult] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/actions/{action} [post] +func AdminRecordAction() {} + // AdminActionRequest is the body of a widget or toolbar action. record_id is // the record a widget on the update form belongs to (absent on create and // always absent for a toolbar action); values is the widget's snapshot of its @@ -499,6 +522,7 @@ func AdminPartial() {} // AdminShow documents the record show route. // // @Summary Show an admin record +// @Description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered. // @Tags admin // @Produce json // @Security BackendBearer diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index d8f4f35..b15cbd1 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -83,6 +83,9 @@ type CompiledController struct { // by name. They have their own namespace next to Actions; create and // delete are reserved there too. BulkActions map[string]pact.AdminBulkAction + // RecordActions are the controller's pact.HasAdminRecordActions entries + // keyed by name, a third namespace with the same reserved names. + RecordActions map[string]pact.AdminRecordAction // scripts and styles are the controller's declared plugin JS and CSS, // in declared order. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 2dbca92..82a6e48 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "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/phrasebook" @@ -346,6 +347,7 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id if ctx == nil { ctx = context.Background() } + ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr)) var result RecordResult err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { tx = tx.WithContext(ctx) @@ -361,6 +363,10 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id return err } result, err = projectFullRecord(ctx, tx, cc, target) + if err != nil { + return err + } + result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target) return err }) if err != nil { @@ -369,6 +375,102 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id return result, nil } +// offeredRecordActions lists the declared record actions the principal on ctx +// may run and that apply to the loaded record, in declared order, with +// localized label and confirm text (D-10). It is nil when none is offered. +// Applies runs with the read transaction on ctx; its error fails the read. +func (s CRUDService) offeredRecordActions(ctx context.Context, cc *CompiledController, record any) ([]RecordAction, error) { + if cc == nil || cc.Form == nil || len(cc.Form.recordActions) == 0 { + return nil, nil + } + principal, _ := bouncer.User(ctx) + var out []RecordAction + for _, name := range cc.Form.recordActions { + action, ok := cc.RecordActions[name] + if !ok || !Allows(principal, action.Permissions) { + continue + } + if action.Applies != nil { + applies, err := action.Applies(ctx, record) + if err != nil { + return nil, actionFailure(cc, "record action", name, err) + } + if !applies { + continue + } + } + out = append(out, RecordAction{ + Name: name, + Label: translateKey(ctx, s.tr, action.Label), + Confirm: translateKey(ctx, s.tr, action.Confirm), + }) + } + return out, nil +} + +// RecordAction runs the declared record action name on one record in a +// transaction. The record is loaded through the controller's FormExtendQuery +// scope with a row lock: a missing or out-of-scope id is not found. The +// action's Applies is checked again inside the transaction, and an action +// that does not apply to the record's current state conflicts. A name the +// form does not declare, or the controller does not register, is not found. +func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, id any, name string) (AdminActionResult, error) { + if s.DB == nil { + return AdminActionResult{}, errors.New("cabana: database is not configured") + } + if ctx == nil { + ctx = context.Background() + } + action, ok := recordActionOf(cc, name) + if !ok { + return AdminActionResult{}, recordNotFound{} + } + if _, err := newWritableModel(cc); err != nil { + return AdminActionResult{}, err + } + ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr)) + result := AdminActionResult{Fill: map[string]any{}} + err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + target, err := newWritableModel(cc) + if err != nil { + return err + } + pk, err := coercePK(target, id) + if err != nil { + return err + } + if err := loadRecord(ctx, tx, cc, target, pk); err != nil { + return err + } + if action.Applies != nil { + applies, err := action.Applies(ctx, target) + if err != nil { + return actionFailure(cc, "record action", name, err) + } + if !applies { + return actionConflict{} + } + } + out, err := action.Run(ctx, pact.AdminRecordActionInput{RecordID: uint64(pkUint(target)), Record: target}) + if err != nil { + return actionFailure(cc, "record action", name, err) + } + result.Message = translateKey(ctx, s.tr, out.Message) + return nil + }) + if err != nil { + return AdminActionResult{}, err + } + return result, nil +} + +// actionConflict is a record action that does not apply to the record's +// current state (409). +type actionConflict struct{} + +func (actionConflict) Error() string { return "cabana: action does not apply" } + // projectFullRecord is the D-18 record shape: scalar writable fields, relation // values keyed by field name, and their labels. func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) { @@ -513,6 +615,11 @@ func writeCRUDError(w http.ResponseWriter, err error) { WriteError(w, http.StatusConflict, "conflict", "Conflict") return } + var stale actionConflict + if errors.As(err, &stale) { + WriteError(w, http.StatusConflict, "conflict", "Conflict") + return + } WriteError(w, http.StatusInternalServerError, "error", msgServerError) } @@ -598,6 +705,10 @@ func lifecycleFailure(cc *CompiledController, err error) error { if errors.As(err, &partial) { return err } + var stale actionConflict + if errors.As(err, &stale) { + return err + } var life *lifecycleError if errors.As(err, &life) { return err diff --git a/modules/cabana/example_actions_test.go b/modules/cabana/example_actions_test.go index 5df1ddb..91c5476 100644 --- a/modules/cabana/example_actions_test.go +++ b/modules/cabana/example_actions_test.go @@ -14,6 +14,7 @@ type Person struct { Name string `gorm:"column:name"` Email string `gorm:"column:email"` Active bool `gorm:"column:active"` + Banned bool `gorm:"column:banned"` } func (Person) TableName() string { return "acme_roster_people" } @@ -21,13 +22,14 @@ func (Person) TableName() string { return "acme_roster_people" } // Fillable lists the columns the admin form may write. func (Person) Fillable() []string { return []string{"name", "email"} } -// PeopleController is an admin controller whose list offers bulk actions. +// PeopleController is an admin controller with bulk and record actions. type PeopleController struct{} var ( - _ pact.AdminController = PeopleController{} - _ pact.AdminRecordSource = PeopleController{} - _ pact.HasAdminBulkActions = PeopleController{} + _ pact.AdminController = PeopleController{} + _ pact.AdminRecordSource = PeopleController{} + _ pact.HasAdminBulkActions = PeopleController{} + _ pact.HasAdminRecordActions = PeopleController{} ) func (PeopleController) ID() string { return "acme.roster.people" } @@ -86,3 +88,44 @@ func (PeopleController) AdminBulkActions() []pact.AdminBulkAction { }, }} } + +// AdminRecordActions registers the actions config_form.yaml offers under +// recordActions. Applies decides whether an action fits the record's current +// state; Run receives the record loaded and locked through the form scope. +func (PeopleController) AdminRecordActions() []pact.AdminRecordAction { + return []pact.AdminRecordAction{{ + Name: "activate", + Label: "acme.roster::lang.people.activate", + Permissions: []string{"acme.roster.manage"}, + Applies: func(_ context.Context, record any) (bool, error) { + return !record.(*Person).Active, nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("no transaction") + } + if err := tx.Model(in.Record).Update("active", true).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil + }, + }, { + Name: "reinstate", + Label: "acme.roster::lang.people.reinstate", + Confirm: "acme.roster::lang.people.reinstate_confirm", + Applies: func(_ context.Context, record any) (bool, error) { + return record.(*Person).Banned, nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("no transaction") + } + if err := tx.Model(in.Record).Update("banned", false).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{}, nil + }, + }} +} diff --git a/modules/cabana/extension.go b/modules/cabana/extension.go index 1ff6363..0e97922 100644 --- a/modules/cabana/extension.go +++ b/modules/cabana/extension.go @@ -72,6 +72,11 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) } cc.BulkActions = bulkActions + recordActions, err := compileRecordActions(cc.Controller) + if err != nil { + return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) + } + cc.RecordActions = recordActions if err := compileClientAssets(pluginID, cc, fsys); err != nil { return err } @@ -81,6 +86,21 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error if cc.Form == nil { return nil } + // Every recordActions name of config_form.yaml must be a record action + // the controller registers, with a label (D-10). + formFile := cc.Form.configPath + if formFile == "" { + formFile = "config_form.yaml" + } + for _, name := range cc.Form.recordActions { + action, ok := recordActions[name] + if !ok { + return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: unsupported action %s (want a record action the controller registers)", name)) + } + if strings.TrimSpace(action.Label) == "" { + return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: action %s needs a label", name)) + } + } file := cc.Form.fieldsPath if file == "" { file = "fields.yaml" @@ -308,3 +328,29 @@ func compileBulkActions(ctl pact.AdminController) (map[string]pact.AdminBulkActi } return out, nil } + +// compileRecordActions collects a controller's registered record actions into +// their own namespace; create and delete stay reserved. +func compileRecordActions(ctl pact.AdminController) (map[string]pact.AdminRecordAction, error) { + out := map[string]pact.AdminRecordAction{} + src, ok := ctl.(pact.HasAdminRecordActions) + if !ok || src == nil { + return out, nil + } + for _, action := range src.AdminRecordActions() { + if !identifier(action.Name) { + return nil, fmt.Errorf("record action name %q is not an identifier", action.Name) + } + if builtinToolbarActions[action.Name] { + return nil, fmt.Errorf("record action %s uses a reserved built-in name (create, delete)", action.Name) + } + if _, dup := out[action.Name]; dup { + return nil, fmt.Errorf("duplicate record action %s", action.Name) + } + if action.Run == nil { + return nil, fmt.Errorf("record action %s has no Run function", action.Name) + } + out[action.Name] = action + } + return out, nil +} diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index 745e82c..c03a9c7 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -54,6 +54,24 @@ type formConfigDocument struct { Create *formRedirects `yaml:"create"` Update *formRedirects `yaml:"update"` Messages *formMessageKeys `yaml:"messages"` + RecordActions recordActionList `yaml:"recordActions"` +} + +// recordActionList is the declarative recordActions list (D-10): the names of +// record actions the controller registers through pact.HasAdminRecordActions, +// in display order. Decode has no controller, so membership is resolved in +// compileExtension. +type recordActionList struct { + items []string +} + +func (l *recordActionList) UnmarshalYAML(node ast.Node) error { + items, err := actionNameList(node, "recordActions", "recordActions must be a list of record action names the controller registers") + if err != nil { + return err + } + l.items = items + return nil } type formRedirects struct { @@ -118,6 +136,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc Fields: fields, redirects: FormRedirects{Default: doc.DefaultRedirect}, fieldsPath: fieldsPath, + configPath: cfgPath, + + recordActions: doc.RecordActions.items, } if doc.Messages != nil { schema.messageKeys = *doc.Messages diff --git a/modules/cabana/http.go b/modules/cabana/http.go index d031bcb..2b0165e 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -276,6 +276,11 @@ func (s *service) mount(r pact.Router) { constrainController(g) g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord)) constrainController(g) + // Declared record actions (D-10): the record is loaded through the + // form scope before plugin code runs. + g.Post("/{vendor}/{plugin}/{controller}/{id}/actions/{action}", requireAjax(s.recordAction)) + constrainController(g) + g.Where("action", "[A-Za-z_][A-Za-z0-9_]*") // Six-segment GET routes share one pattern: ServeMux rejects the // relation list next to the field options route (neither is more // specific), so nestedGet dispatches on the literal segments. diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 141f6d6..8a4e1be 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -279,6 +279,18 @@ func TestPhase10OpenAPIConformance(t *testing.T) { spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true) return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true) }, into[cabana.Envelope[cabana.BulkResult]](), nil}, + {"POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + shown := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true) + if !strings.Contains(shown.Body.String(), `"actions":[{"name":"ping","label":"Ping"}]`) { + t.Fatalf("show does not offer the record action: %s", shown.Body.String()) + } + rec := e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/actions/ping", e.gadgetID), map[string]any{}, true) + var body cabana.Envelope[cabana.AdminActionResult] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Message != "Pinged" || body.Data.Fill == nil || len(body.Data.Fill) != 0 { + t.Fatalf("record action body = %s (%v)", rec.Body.String(), err) + } + return rec + }, into[cabana.Envelope[cabana.AdminActionResult]](), nil}, {"POST /{vendor}/{plugin}/{controller}/bulk/{action}", 200, "cabana.Envelope-cabana_BulkActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk/touch", map[string]any{"ids": []uint{e.gadgetID}}, true) var body cabana.Envelope[cabana.BulkActionResult] @@ -808,6 +820,17 @@ func (conformController) AdminBulkActions() []pact.AdminBulkAction { }, }} } + +// AdminRecordActions registers the record action the form's recordActions +// declares. +func (conformController) AdminRecordActions() []pact.AdminRecordAction { + return []pact.AdminRecordAction{{ + Name: "ping", Label: "Ping", Permissions: []string{"acme.conform.access"}, + Run: func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + return pact.AdminRecordActionResult{Message: "Pinged"}, nil + }, + }} +} func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} } // PartialData supplies curated view models, never the gadget model itself. @@ -892,6 +915,7 @@ create: update: redirect: acme/conform/gadgets redirectClose: acme/conform/gadgets +recordActions: [ping] `), "controllers/gadgets/config_relation.yaml": file(`members: label: Members diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index b171df0..b235721 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -99,6 +99,7 @@ func TestPhase10Coverage(t *testing.T) { "POST /{vendor}/{plugin}/{controller}/bulk/{action}", "POST /{vendor}/{plugin}/{controller}/toolbar/{action}", "POST /{vendor}/{plugin}/{controller}/widgets/{field}", + "POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", @@ -115,7 +116,7 @@ func TestPhase10Coverage(t *testing.T) { "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 24 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 25 besides login):\n%s", strings.Join(unsafe, "\n")) } // The routes added in Phase 10 are safe reads: GET /lang and the shared // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index 9a57509..200124a 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -67,12 +67,13 @@ func TestPhase10CSRF(t *testing.T) { }) } // refresh, logout, settings put, create, bulk-delete, bulk action, - // widget action, toolbar action, update, delete, link, unlink, file + // widget action, toolbar action, update, delete, record action, link, + // unlink, file // upload, file reorder, file caption, file remove, relation child // create, update and delete, pivot update, child file upload, reorder, // caption and remove - if unsafe != 24 { - t.Fatalf("walked %d state-changing routes, want 24: %v", unsafe, router.order) + if unsafe != 25 { + t.Fatalf("walked %d state-changing routes, want 25: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/phase121_actions_test.go b/modules/cabana/phase121_actions_test.go index 8d310fc..7e9811a 100644 --- a/modules/cabana/phase121_actions_test.go +++ b/modules/cabana/phase121_actions_test.go @@ -12,7 +12,10 @@ import ( "testing" "testing/fstest" + "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/party" ) const rosterPeople = "/acme/roster/people" @@ -249,3 +252,205 @@ func TestListSchemaBulkActionsBoot(t *testing.T) { } }) } + +// rosterOffered returns the record action names the show response offers. +func rosterOffered(t *testing.T, env *rosterEnv, id uint, auth string) []string { + t.Helper() + rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, id), "", auth) + var body cabana.RecordEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("show body %s: %v", rec.Body.String(), err) + } + names := []string{} + for _, action := range body.Meta.Actions { + names = append(names, action.Name) + } + return names +} + +// TestRecordActionSmoke drives a declared record action through the assembled +// router on PostgreSQL (D-10; T-12.1-02, T-12.1-03, T-12.1-04): the offered +// actions of a shown record, the form scope, Applies inside the transaction, +// the action permission, the strict body and the CSRF header. +func TestRecordActionSmoke(t *testing.T) { + env, gdb := newRosterEnv(t) + idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Email: "idle@example.test"}) + banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Banned", Active: true, Banned: true}) + foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed"}) + action := func(id uint, name string) string { + return fmt.Sprintf("%s/%d/actions/%s", rosterPeople, id, name) + } + + t.Run("show offers the permitted actions that apply", func(t *testing.T) { + if got := rosterOffered(t, env, idle, "bearer"); !reflect.DeepEqual(got, []string{"activate"}) { + t.Fatalf("idle person, full admin: %v", got) + } + if got := rosterOffered(t, env, banned, "bearer"); !reflect.DeepEqual(got, []string{"reinstate"}) { + t.Fatalf("banned person, full admin: %v", got) + } + // The limited admin lacks acme.roster.manage: no activate. + if got := rosterOffered(t, env, idle, "limited"); len(got) != 0 { + t.Fatalf("idle person, limited admin: %v", got) + } + rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, banned), "", "bearer") + if !strings.Contains(rec.Body.String(), `"actions":[{"name":"reinstate","label":"Reinstate","confirm":"Lift the ban on this person?"}]`) { + t.Fatalf("offered action is not localized: %s", rec.Body.String()) + } + // A record with no offered action has no actions key at all. + rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, idle), "", "limited") + if strings.Contains(rec.Body.String(), `"actions"`) { + t.Fatalf("meta.actions sent without an offered action: %s", rec.Body.String()) + } + }) + + t.Run("create and update responses carry no actions", func(t *testing.T) { + rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh"}`, "bearer") + if strings.Contains(rec.Body.String(), `"actions"`) { + t.Fatalf("create response: %s", rec.Body.String()) + } + rec = env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, idle), `{"name":"Idle"}`, "bearer") + if strings.Contains(rec.Body.String(), `"actions"`) { + t.Fatalf("update response: %s", rec.Body.String()) + } + }) + + t.Run("a controller without record actions sends no actions key", func(t *testing.T) { + demo, demoDB := newActEnv(t) + gadget := actInsert(t, demoDB, "plain", "acme") + rec := demo.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("/acme/demo/gadgets/%d", gadget), "", "bearer") + if strings.Contains(rec.Body.String(), `"actions"`) || !strings.Contains(rec.Body.String(), `"labels"`) { + t.Fatalf("show = %s", rec.Body.String()) + } + }) + + t.Run("limited admin is refused", func(t *testing.T) { + rec := env.expect(t, http.StatusForbidden, http.MethodPost, action(idle, "activate"), `{}`, "limited") + actErrorCode(t, rec.Body.Bytes(), "forbidden") + if rosterLoad(t, gdb, idle).Active { + t.Fatal("a denied admin changed the record") + } + }) + + t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) { + rec := env.expect(t, http.StatusForbidden, http.MethodPost, action(idle, "activate"), `{}`, "cookie-only") + actErrorCode(t, rec.Body.Bytes(), "forbidden") + if rosterLoad(t, gdb, idle).Active { + t.Fatal("a request without the CSRF header changed the record") + } + }) + + t.Run("strict body", func(t *testing.T) { + for _, body := range []string{fmt.Sprintf(`{"record_id":%d}`, idle), `{"values":{}}`, `{"extra":1}`, `{} {}`, ``} { + rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, action(idle, "activate"), body, "bearer") + actErrorCode(t, rec.Body.Bytes(), "validation_failed") + } + if rosterLoad(t, gdb, idle).Active { + t.Fatal("a malformed body changed the record") + } + }) + + t.Run("out-of-scope and missing records are 404", func(t *testing.T) { + for _, id := range []uint{foreign, 999999} { + env.expect(t, http.StatusNotFound, http.MethodPost, action(id, "activate"), `{}`, "bearer") + } + if rosterLoad(t, gdb, foreign).Active { + t.Fatal("an out-of-scope record was activated") + } + }) + + t.Run("undeclared and reserved names are 404", func(t *testing.T) { + for _, name := range []string{"missing", "archive", "delete", "create"} { + env.expect(t, http.StatusNotFound, http.MethodPost, action(idle, name), `{}`, "bearer") + } + }) + + if calls := env.spy.takeRecord(); len(calls) != 0 { + t.Fatalf("a refused request reached the plugin: %+v", calls) + } + + t.Run("runs once, then no longer applies", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodPost, action(idle, "activate"), `{}`, "bearer") + result := actResult(t, rec) + if result.Message != "The person was activated." || result.Fill == nil || len(result.Fill) != 0 { + t.Fatalf("result = %+v", result) + } + if !rosterLoad(t, gdb, idle).Active { + t.Fatal("the record was not activated") + } + calls := env.spy.takeRecord() + person, ok := calls[0].Record.(*rosterPerson) + if len(calls) != 1 || calls[0].RecordID != uint64(idle) || !ok || person.ID != idle || person.Tenant != "acme" { + t.Fatalf("input = %+v", calls) + } + // Applies is checked again inside the transaction. + rec = env.expect(t, http.StatusConflict, http.MethodPost, action(idle, "activate"), `{}`, "bearer") + actErrorCode(t, rec.Body.Bytes(), "conflict") + if calls := env.spy.takeRecord(); len(calls) != 0 { + t.Fatalf("the action ran for a record it does not apply to: %+v", calls) + } + if got := rosterOffered(t, env, idle, "bearer"); len(got) != 0 { + t.Fatalf("offered after the run: %v", got) + } + }) + + t.Run("an action without its own permission runs for the limited admin", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodPost, action(banned, "reinstate"), `{}`, "limited") + if result := actResult(t, rec); result.Message != "" { + t.Fatalf("result = %+v", result) + } + if rosterLoad(t, gdb, banned).Banned { + t.Fatal("the ban was not lifted") + } + env.spy.takeRecord() + }) +} + +// TestFormSchemaRecordActionsBoot checks the fail-loud compile of the +// recordActions key (D-10). +func TestFormSchemaRecordActionsBoot(t *testing.T) { + fields, err := os.ReadFile(filepath.Join(rosterDir, "models/person/fields.yaml")) + if err != nil { + t.Fatal(err) + } + const head = "form: ~/plugins/acme/roster/models/person/fields.yaml\nmodelClass: Person\n" + for _, tc := range []struct { + name, yaml, want string + }{ + {"duplicate", head + "recordActions: [activate, activate]\n", "recordActions: duplicate action activate"}, + {"scalar", head + "recordActions: activate\n", "recordActions must be a list of record action names the controller registers"}, + } { + t.Run(tc.name, func(t *testing.T) { + fsys := fstest.MapFS{ + "controllers/people/config_form.yaml": &fstest.MapFile{Data: []byte(tc.yaml)}, + "models/person/fields.yaml": &fstest.MapFile{Data: fields}, + } + _, err := cabana.CompileForm("acme.roster", rosterController{}, fsys) + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + + // A name the controller does not register is refused when the controller + // is activated, where the form meets its registered actions. + t.Run("unregistered", func(t *testing.T) { + fsys := fstest.MapFS{} + for _, name := range []string{"controllers/people/config_list.yaml", "models/person/columns.yaml", "models/person/fields.yaml"} { + data, err := os.ReadFile(filepath.Join(rosterDir, name)) + if err != nil { + t.Fatal(err) + } + fsys[name] = &fstest.MapFile{Data: data} + } + fsys["controllers/people/config_form.yaml"] = &fstest.MapFile{Data: []byte(head + "recordActions: [activate, promote]\n")} + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + if err != nil { + t.Fatal(err) + } + _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}}) + const want = "recordActions: unsupported action promote (want a record action the controller registers)" + if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.roster.people") || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") { + t.Fatalf("error = %v, want %q", err, want) + } + }) +} diff --git a/modules/cabana/phase121_fixture_test.go b/modules/cabana/phase121_fixture_test.go index 44c3d6f..8f9f54e 100644 --- a/modules/cabana/phase121_fixture_test.go +++ b/modules/cabana/phase121_fixture_test.go @@ -46,8 +46,23 @@ func (rosterPerson) Rules() map[string]string { return map[string]string{"name": // rosterSpy records what each registered action's Run receives. type rosterSpy struct { - mu sync.Mutex - bulk []pact.AdminBulkActionInput + mu sync.Mutex + bulk []pact.AdminBulkActionInput + record []pact.AdminRecordActionInput +} + +func (s *rosterSpy) recordOne(in pact.AdminRecordActionInput) { + s.mu.Lock() + defer s.mu.Unlock() + s.record = append(s.record, in) +} + +func (s *rosterSpy) takeRecord() []pact.AdminRecordActionInput { + s.mu.Lock() + defer s.mu.Unlock() + out := s.record + s.record = nil + return out } func (s *rosterSpy) recordBulk(in pact.AdminBulkActionInput) { @@ -64,7 +79,12 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput { return out } -type rosterPlugin struct{ spy *rosterSpy } +// rosterPlugin is the acme.roster fixture plugin. fsys, when set, replaces +// the fixture tree (boot-error tests). +type rosterPlugin struct { + spy *rosterSpy + fsys fs.FS +} func (rosterPlugin) ID() string { return "acme.roster" } func (rosterPlugin) Requires() []string { return nil } @@ -76,7 +96,12 @@ func (p rosterPlugin) AdminControllers() []pact.AdminController { func (rosterPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}} } -func (rosterPlugin) AdminFS() fs.FS { return os.DirFS(rosterDir) } +func (p rosterPlugin) AdminFS() fs.FS { + if p.fsys != nil { + return p.fsys + } + return os.DirFS(rosterDir) +} // LangFS serves only the fixture's lang/ tree. func (rosterPlugin) LangFS() fs.FS { @@ -153,6 +178,46 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction { }} } +// AdminRecordActions: activate needs acme.roster.manage and applies to a +// person who is not active; reinstate applies to a banned person and lifts +// the ban. +func (c rosterController) AdminRecordActions() []pact.AdminRecordAction { + return []pact.AdminRecordAction{{ + Name: "activate", Label: "acme.roster::lang.people.activate", + Permissions: []string{"acme.roster.manage"}, + Applies: func(_ context.Context, record any) (bool, error) { + return !record.(*rosterPerson).Active, nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + c.spy.recordOne(in) + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context") + } + if err := tx.Model(in.Record).Update("active", true).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil + }, + }, { + Name: "reinstate", Label: "acme.roster::lang.people.reinstate", Confirm: "acme.roster::lang.people.reinstate_confirm", + Applies: func(_ context.Context, record any) (bool, error) { + return record.(*rosterPerson).Banned, nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + c.spy.recordOne(in) + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context") + } + if err := tx.Model(in.Record).Update("banned", false).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{}, nil + }, + }} +} + // rosterEnv is the assembled admin API over the roster fixture. The embedded // actEnv supplies call and expect with the four auth modes: bearer (developer // token), limited (acme.roster.access only), cookie and cookie-only. diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index 8dddce4..232e1c8 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -232,6 +232,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error { return err } } + for name, action := range controller.RecordActions { + if err := reg.validatePermissions("record action "+id+"."+name, action.Permissions); err != nil { + return err + } + } } for _, item := range reg.navigation { if err := reg.validateNavigation(item); err != nil { diff --git a/modules/cabana/relation_field.go b/modules/cabana/relation_field.go index f77d1db..c138d29 100644 --- a/modules/cabana/relation_field.go +++ b/modules/cabana/relation_field.go @@ -55,9 +55,13 @@ type RelationOption struct { } // RecordMeta is the record envelope meta: display labels per relation field, -// in the same order as the ids in data. +// in the same order as the ids in data. Actions are the declared record +// actions the requesting admin may run and that apply to the record, in +// declared order; only the show response fills it, and it is omitted when no +// action is offered. type RecordMeta struct { - Labels map[string][]RelationOption `json:"labels"` + Labels map[string][]RelationOption `json:"labels"` + Actions []RecordAction `json:"actions,omitempty"` } // RecordEnvelope is the show, create and update response. diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index c55de86..6013c9d 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -136,6 +136,10 @@ type FormSchema struct { redirects FormRedirects // fieldsPath is the fields.yaml the form was compiled from, for boot errors. fieldsPath string + // configPath is the config_form.yaml the form was compiled from. + configPath string + // recordActions are the declared recordActions names, in declared order. + recordActions []string } // FormView is one request's localized form, including the locale actually used. @@ -181,6 +185,15 @@ type ToolbarAction struct { Label string `json:"label"` } +// RecordAction is one record action offered for a shown record (D-10): the +// name posted to .../{id}/actions/{action}, its localized label and its own +// confirmation question, empty when the admin should show its default text. +type RecordAction struct { + Name string `json:"name"` + Label string `json:"label"` + Confirm string `json:"confirm,omitempty"` +} + // FormRedirects are config_form.yaml defaultRedirect, create.* and update.*. type FormRedirects struct { Default string `json:"default"` diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index b2d2f95..a03ce71 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -59,6 +59,7 @@ var phase09Routes = []adminRoute{ {key: "GET /{vendor}/{plugin}/{controller}/{id}"}, {key: "PUT /{vendor}/{plugin}/{controller}/{id}"}, {key: "DELETE /{vendor}/{plugin}/{controller}/{id}"}, + {key: "POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, @@ -292,6 +293,7 @@ func phase09ProtectedCalls() []phase09Call { {"create", (*service).create}, {"bulk-delete", (*service).bulkDelete}, {"bulk-action", (*service).bulkAction}, + {"record-action", (*service).recordAction}, {"widget-action", (*service).widgetAction}, {"toolbar-action", (*service).toolbarAction}, {"partial", (*service).partial}, diff --git a/modules/cabana/testdata/roster/controllers/people/config_form.yaml b/modules/cabana/testdata/roster/controllers/people/config_form.yaml index b5780b7..9a8a5e6 100644 --- a/modules/cabana/testdata/roster/controllers/people/config_form.yaml +++ b/modules/cabana/testdata/roster/controllers/people/config_form.yaml @@ -8,3 +8,4 @@ create: update: redirect: acme/roster/people redirectClose: acme/roster/people +recordActions: [activate, reinstate] diff --git a/modules/cabana/testdata/roster/lang/en/lang.yaml b/modules/cabana/testdata/roster/lang/en/lang.yaml index 87b411e..326cff1 100644 --- a/modules/cabana/testdata/roster/lang/en/lang.yaml +++ b/modules/cabana/testdata/roster/lang/en/lang.yaml @@ -8,3 +8,6 @@ people: activate_confirm: Activate the selected people? archive: Archive archived: The selected people were archived. + activated: The person was activated. + reinstate: Reinstate + reinstate_confirm: Lift the ban on this person? diff --git a/modules/cabana/testdata/roster/lang/pl/lang.yaml b/modules/cabana/testdata/roster/lang/pl/lang.yaml index 89b6836..4a3c57f 100644 --- a/modules/cabana/testdata/roster/lang/pl/lang.yaml +++ b/modules/cabana/testdata/roster/lang/pl/lang.yaml @@ -8,3 +8,6 @@ people: activate_confirm: Aktywować zaznaczone osoby? archive: Archiwizuj archived: Zaznaczone osoby zostały zarchiwizowane. + activated: Osoba została aktywowana. + reinstate: Przywróć + reinstate_confirm: Zdjąć blokadę z tej osoby? diff --git a/modules/pact/README.md b/modules/pact/README.md index 7f979f5..231929a 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -14,7 +14,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf - HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`. - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. - 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), and `pact.AdminPartialData` (the curated view model a partial template renders). +- 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). - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), 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`). - 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. @@ -113,6 +113,10 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. | | `pact.AdminBulkActionResult` | What a bulk action returns: an optional message for the toast and `Affected`, the number of records it changed. | | `pact.HasAdminBulkActions` | Registers a controller's bulk actions for the `bulkActions` list of `config_list.yaml`. | +| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the optional `Applies` rule and the Go `Run` function. | +| `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.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | | `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. | | `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index c1029d4..83e4924 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -299,6 +299,50 @@ type HasAdminBulkActions interface { AdminBulkActions() []AdminBulkAction } +// AdminRecordAction is one controller action an administrator runs on a +// single record (config_form.yaml recordActions). The admin framework owns the +// HTTP route, the CSRF check, authentication, the record lookup and the +// transaction; Run only carries the business logic and reads the write +// transaction with cabana.TxFromContext. Name is an identifier unique among +// the controller's record actions; create and delete are reserved, and a bulk +// action may use the same name. Label and Confirm are phrase keys or literal +// text; an empty Confirm means the framework's default confirm text. +// Permissions are checked in addition to the controller's +// RequiredPermissions. Applies reports whether the action applies to the +// record in its current state: a nil Applies means the action always applies. +// Applies must be a pure read, because it also runs when a record is shown, to +// decide which actions the admin is offered. +type AdminRecordAction struct { + Name string + Label string + Confirm string + Permissions []string + Applies func(ctx context.Context, record any) (bool, error) `json:"-"` + Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"` +} + +// AdminRecordActionInput is what the framework hands an AdminRecordAction. +// Record is the record the framework loaded through the controller's +// FormExtendQuery scope with a row lock, inside the action's transaction; +// RecordID is its primary key. +type AdminRecordActionInput struct { + RecordID uint64 + Record any +} + +// AdminRecordActionResult is a record action's answer. Message is a phrase key +// or text, localized by the framework and shown as a toast; when it is empty +// the admin shows its default text. +type AdminRecordActionResult struct { + Message string +} + +// HasAdminRecordActions is implemented by an admin controller that registers +// named record actions for its config_form.yaml recordActions list. +type HasAdminRecordActions interface { + AdminRecordActions() []AdminRecordAction +} + // AdminPartialData supplies the view model a controller partial template // renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name // is the partial name; record is the scoped record for a form partial on an diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index d719bbf..d423574 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -91,6 +91,9 @@ form: tab_errors: one: ":count error" other: ":count errors" + action_confirm: "Run “:action” on this record?" + action_done: Action completed. + action_stale: This action no longer applies to this record. The page has been refreshed. relation: add: Add link: Link diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 9884ec2..de28932 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -101,6 +101,9 @@ form: few: ":count błędy" many: ":count błędów" other: ":count błędu" + action_confirm: "Wykonać „:action” na tym rekordzie?" + action_done: Akcja została wykonana. + action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona. relation: add: Dodaj link: Dołącz