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:
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>
|
||||
Reference in New Issue
Block a user