feat(12.1-01): declared record actions with an applicability rule
- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
@@ -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.",
|
||||
|
||||
111
admin/src/api/schema.d.ts
vendored
111
admin/src/api/schema.d.ts
vendored
@@ -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"][];
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
120
admin/src/components/form/RecordActions.vue
Normal file
120
admin/src/components/form/RecordActions.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { ControllerParams, RecordAction } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
import Button from '../ui/Button.vue'
|
||||
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
|
||||
// Record actions (UI-SPEC S2, D-10): one outline button per action the
|
||||
// record response offers, in the given order. The server already reduced the
|
||||
// list to what the admin may run and what applies to the record, so an action
|
||||
// that is not listed is not rendered. Every action asks for confirmation; the
|
||||
// dialog stays open and busy while the POST runs, and every button here is
|
||||
// disabled meanwhile. Labels, confirm texts and messages come from the server
|
||||
// and are rendered as text.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
source: ControllerParams
|
||||
recordId: number
|
||||
actions: RecordAction[]
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
/** true while a request runs, so the host can disable its other buttons. */
|
||||
busy: [running: boolean]
|
||||
/** The action ran: the toast text (server message or the default). */
|
||||
done: [text: string]
|
||||
/** 409: the action no longer applies; the host reloads the record. */
|
||||
stale: []
|
||||
/** 404: the record is gone or out of scope; the host shows its load failure. */
|
||||
gone: []
|
||||
}>()
|
||||
|
||||
const confirm = useConfirm()
|
||||
const running = ref(false)
|
||||
|
||||
async function run(action: RecordAction): Promise<void> {
|
||||
if (running.value || props.disabled) {
|
||||
return
|
||||
}
|
||||
let status = 0
|
||||
let ok = false
|
||||
let text = ''
|
||||
const confirmed = await confirm.ask(
|
||||
{
|
||||
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
|
||||
confirmLabel: action.label,
|
||||
danger: false,
|
||||
},
|
||||
async () => {
|
||||
running.value = true
|
||||
emit('busy', true)
|
||||
try {
|
||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/actions/{action}', {
|
||||
params: { path: { ...props.source, id: props.recordId, action: action.name } },
|
||||
body: {},
|
||||
})
|
||||
status = result.response.status
|
||||
ok = !!result.data
|
||||
text = result.data?.data.message ?? result.error?.error.message ?? ''
|
||||
} catch {
|
||||
status = 0
|
||||
} finally {
|
||||
running.value = false
|
||||
emit('busy', false)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
if (ok) {
|
||||
emit('done', text || t('backend::lang.form.action_done'))
|
||||
return
|
||||
}
|
||||
if (status === 409) {
|
||||
showToast(t('backend::lang.form.action_stale'), 'danger')
|
||||
emit('stale')
|
||||
return
|
||||
}
|
||||
if (status === 404) {
|
||||
emit('gone')
|
||||
return
|
||||
}
|
||||
if (status === 403) {
|
||||
showToast(text || t('backend::lang.list.action_forbidden'), 'danger')
|
||||
return
|
||||
}
|
||||
showToast(text || t('backend::lang.extension.action_failed'), 'danger')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="actions.length > 0">
|
||||
<Button
|
||||
v-for="action in actions"
|
||||
:key="action.name"
|
||||
variant="outline"
|
||||
size="md"
|
||||
:data-record-action="action.name"
|
||||
:disabled="disabled || running"
|
||||
@click="run(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
:busy="confirm.busy.value"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
10
admin/tests/fixtures/roster.record.json
vendored
Normal file
10
admin/tests/fixtures/roster.record.json
vendored
Normal file
@@ -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?" }
|
||||
]
|
||||
}
|
||||
}
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -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
|
||||
|
||||
@@ -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<string, Route> = {}): Record<string, Route> {
|
||||
@@ -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<string, Route>, 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: `<div>
|
||||
<RecordActions :source="source" :record-id="1" :actions="actions" :disabled="disabled"
|
||||
@busy="(v) => $emit('busy', v)" @done="(v) => $emit('done', v)" @stale="$emit('stale')" @gone="$emit('gone')" />
|
||||
<ToastHost />
|
||||
</div>`,
|
||||
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<HTMLButtonElement>('[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<Reply>((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<HTMLButtonElement>('[data-action="confirm"]')!.getAttribute('aria-busy')).toBe('true')
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,6 +27,8 @@ update:
|
||||
|
||||
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` 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
|
||||
|
||||
@@ -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: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.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 `<prefix>/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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -8,3 +8,4 @@ create:
|
||||
update:
|
||||
redirect: acme/roster/people
|
||||
redirectClose: acme/roster/people
|
||||
recordActions: [activate, reinstate]
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user