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:
Jakub Zych
2026-10-04 23:37:30 +02:00
parent a879d6388c
commit e0ccced76a
34 changed files with 1350 additions and 24 deletions

View File

@@ -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"][];
};

View File

@@ -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. */

View 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>