From 9a5f27e86cb0540c0bb041174d4d21735c557591 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 16:38:37 +0200 Subject: [PATCH] feat(19.7-01): admin SPA account contributions and prompted record actions - 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 --- admin/src/api/types.ts | 8 + .../account/AccountContributions.vue | 126 ++++++++++++++++ admin/src/components/form/RecordActions.vue | 45 +++++- admin/src/components/ui/ConfirmDialog.vue | 51 ++++++- admin/src/components/ui/confirm.ts | 24 ++- admin/src/views/AccountView.vue | 3 + .../account/AccountContributions.test.ts | 125 ++++++++++++++++ admin/tests/form/RecordActionsPrompt.test.ts | 140 ++++++++++++++++++ 8 files changed, 512 insertions(+), 10 deletions(-) create mode 100644 admin/src/components/account/AccountContributions.vue create mode 100644 admin/tests/account/AccountContributions.test.ts create mode 100644 admin/tests/form/RecordActionsPrompt.test.ts diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 1e1d492a..16d11bcf 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -59,6 +59,14 @@ export type BulkResult = Schemas['cabana.BulkResult'] export type BulkAction = Schemas['cabana.BulkAction'] /** A record action the record response offers: name, label and confirm text. */ export type RecordAction = Schemas['cabana.RecordAction'] +/** The text input a prompted record action asks for in its confirm dialog. */ +export type RecordActionPrompt = Schemas['cabana.RecordActionPrompt'] +/** A section a plugin contributes to My account: label, status and offered actions. */ +export type AccountContribution = Schemas['cabana.AccountContributionView'] +/** One action of a contributed My account section. */ +export type AccountContributionAction = Schemas['cabana.AccountContributionActionView'] +/** An account contribution action's answer: the toast message and an optional URL. */ +export type AccountActionResult = Schemas['cabana.AccountActionResultView'] /** A declared bulk action's answer: the toast message and the affected count. */ export type BulkActionResult = Schemas['cabana.BulkActionResult'] /** Body of a widget action: the record id (absent on create) and fill values. */ diff --git a/admin/src/components/account/AccountContributions.vue b/admin/src/components/account/AccountContributions.vue new file mode 100644 index 00000000..b2572c7a --- /dev/null +++ b/admin/src/components/account/AccountContributions.vue @@ -0,0 +1,126 @@ + + + diff --git a/admin/src/components/form/RecordActions.vue b/admin/src/components/form/RecordActions.vue index e60bc4c5..f6c02f11 100644 --- a/admin/src/components/form/RecordActions.vue +++ b/admin/src/components/form/RecordActions.vue @@ -16,6 +16,8 @@ import { useConfirm } from '../ui/confirm' // 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 @@ -39,6 +41,16 @@ const emit = defineEmits<{ 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 } } | 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 { if (running.value || props.disabled) { return @@ -46,23 +58,41 @@ async function run(action: RecordAction): Promise { 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 result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/actions/{action}', { - params: { path: { ...props.source, id: props.recordId, action: action.name } }, - body: {}, - }) + 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 { @@ -91,6 +121,10 @@ async function run(action: RecordAction): Promise { 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') } @@ -114,6 +148,9 @@ async function run(action: RecordAction): Promise { :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" /> diff --git a/admin/src/components/ui/ConfirmDialog.vue b/admin/src/components/ui/ConfirmDialog.vue index a273aec4..b469ab0b 100644 --- a/admin/src/components/ui/ConfirmDialog.vue +++ b/admin/src/components/ui/ConfirmDialog.vue @@ -1,4 +1,5 @@