- AccountContributions.vue on My account: one card per contributed section
with status and outline buttons; confirm or danger actions go through the
shared ConfirmDialog; a returned URL opens in a new tab (noopener) and stays
as a visible link; 409 reloads with the stale toast
- ConfirmRequest.prompt and useConfirm value; ConfirmDialog renders one
labelled text input (maxlength, autocomplete off, aria-describedby comment,
autofocus), confirm disabled until a value is typed, Enter confirms
- RecordActions posts {payload} for a prompted action and {} otherwise; a 422
on payload shows the server's detail as a danger toast
- types.ts exports RecordActionPrompt, AccountContribution,
AccountContributionAction, AccountActionResult
- vitest smokes AccountContributions.test.ts and RecordActionsPrompt.test.ts
159 lines
5.1 KiB
Vue
159 lines
5.1 KiB
Vue
<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. A `danger` action (UI-SPEC F5) turns the dialog's
|
|
// confirm button into the danger variant; the footer button stays outline.
|
|
// An action with a `prompt` asks for one text value in the dialog and posts
|
|
// it as the body key `payload`; every other action posts an empty body.
|
|
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)
|
|
|
|
/** The server's first message on the payload field of a 422, if any. */
|
|
function payloadDetail(error: unknown): string {
|
|
const details = (error as { error?: { details?: Record<string, unknown> } } | undefined)?.error?.details
|
|
const payload = details?.payload
|
|
if (Array.isArray(payload) && typeof payload[0] === 'string') {
|
|
return payload[0]
|
|
}
|
|
return ''
|
|
}
|
|
|
|
async function run(action: RecordAction): Promise<void> {
|
|
if (running.value || props.disabled) {
|
|
return
|
|
}
|
|
let status = 0
|
|
let ok = false
|
|
let text = ''
|
|
let detail = ''
|
|
const confirmed = await confirm.ask(
|
|
{
|
|
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
|
|
confirmLabel: action.label,
|
|
danger: action.danger,
|
|
prompt: action.prompt
|
|
? {
|
|
label: action.prompt.label,
|
|
placeholder: action.prompt.placeholder,
|
|
comment: action.prompt.comment,
|
|
maxLength: action.prompt.max_length,
|
|
}
|
|
: undefined,
|
|
},
|
|
async () => {
|
|
running.value = true
|
|
emit('busy', true)
|
|
try {
|
|
const params = { path: { ...props.source, id: props.recordId, action: action.name } }
|
|
// A prompted action posts its value as `payload`; every other action
|
|
// keeps the empty body the record action route expects.
|
|
const result = action.prompt
|
|
? await api.POST('/{vendor}/{plugin}/{controller}/{id}/actions/{action}', {
|
|
params,
|
|
body: { payload: confirm.value.value.trim() },
|
|
})
|
|
: await api.POST('/{vendor}/{plugin}/{controller}/{id}/actions/{action}', {
|
|
params,
|
|
body: {},
|
|
})
|
|
status = result.response.status
|
|
ok = !!result.data
|
|
text = result.data?.data.message ?? result.error?.error.message ?? ''
|
|
detail = payloadDetail(result.error)
|
|
} 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
|
|
}
|
|
if (status === 422 && detail) {
|
|
showToast(detail, '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"
|
|
:prompt="confirm.request.value?.prompt ?? null"
|
|
:value="confirm.value.value"
|
|
@update:value="confirm.setValue"
|
|
@confirm="confirm.confirm"
|
|
@cancel="confirm.cancel"
|
|
/>
|
|
</template>
|
|
</template>
|