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
This commit is contained in:
Jakub Zych
2026-10-22 16:38:37 +02:00
parent 9f14cf603c
commit 9a5f27e86c
8 changed files with 512 additions and 10 deletions

View File

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

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../../api/client'
import type { AccountContribution, AccountContributionAction } from '../../api/types'
import { t } from '../../app/i18n'
import Button from '../ui/Button.vue'
import type { useConfirm } from '../ui/confirm'
import { showToast } from '../../state/useToasts'
// My account sections plugins contribute (phase 19.7, D-04 part 1): one card
// per section with its status line and one outline button per action. The
// server already reduced the actions to what the admin may run and what
// applies to them; labels, descriptions, statuses and messages arrive
// translated and render as text. Nothing renders while the list is empty.
// Shares AccountView's useConfirm() so ConfirmDialog stays at z-[90].
const props = defineProps<{
confirm: ReturnType<typeof useConfirm>
}>()
const sections = ref<AccountContribution[]>([])
const running = ref('')
/** The URL the last action answered, per section, kept as a visible link. */
const links = ref<Record<string, string>>({})
async function load(): Promise<void> {
try {
const result = await api.GET('/account/contributions')
sections.value = result.data?.data ?? []
} catch {
sections.value = []
}
}
async function post(section: AccountContribution, action: AccountContributionAction): Promise<void> {
running.value = `${section.name}/${action.name}`
try {
const result = await api.POST('/account/contributions/{name}/actions/{action}', {
params: { path: { name: section.name, action: action.name } },
})
if (result.data) {
const answer = result.data.data
showToast(answer.message || t('backend::lang.form.action_done'))
if (answer.url) {
links.value = { ...links.value, [section.name]: answer.url }
window.open(answer.url, '_blank', 'noopener')
}
await load()
return
}
const status = result.response.status
if (status === 409) {
showToast(t('backend::lang.form.action_stale'), 'danger')
await load()
return
}
if (status === 403) {
showToast(result.error?.error.message || t('backend::lang.list.action_forbidden'), 'danger')
return
}
showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger')
} catch {
showToast(t('backend::lang.extension.action_failed'), 'danger')
} finally {
running.value = ''
}
}
async function run(section: AccountContribution, action: AccountContributionAction): Promise<void> {
if (running.value) {
return
}
if (!action.confirm && !action.danger) {
await post(section, action)
return
}
await props.confirm.ask(
{
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
confirmLabel: action.label,
danger: action.danger,
},
() => post(section, action),
)
}
onMounted(() => {
void load()
})
</script>
<template>
<div
v-for="section in sections"
:key="section.name"
:data-account-contribution="section.name"
class="flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card"
>
<div class="flex flex-col gap-1">
<h2 class="text-[18px] font-bold">{{ section.label }}</h2>
<p v-if="section.description" class="max-w-[640px] text-[13px] text-muted">{{ section.description }}</p>
</div>
<p v-if="section.status" data-account-contribution-status class="font-medium">{{ section.status }}</p>
<a
v-if="links[section.name]"
data-account-contribution-url
:href="links[section.name]"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline [overflow-wrap:anywhere]"
>{{ links[section.name] }}</a
>
<div v-if="section.actions.length > 0" class="flex flex-wrap gap-2.5">
<Button
v-for="action in section.actions"
:key="action.name"
variant="outline"
size="md"
:data-account-contribution-action="action.name"
:disabled="running !== ''"
@click="run(section, action)"
>
{{ action.label }}
</Button>
</div>
</div>
</template>

View File

@@ -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<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
@@ -46,23 +58,41 @@ async function run(action: RecordAction): Promise<void> {
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<void> {
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>
@@ -114,6 +148,9 @@ async function run(action: RecordAction): Promise<void> {
: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"
/>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import {
AlertDialogCancel,
AlertDialogContent,
@@ -10,11 +11,15 @@ import {
} from 'reka-ui'
import { LoaderCircle } from '@lucide/vue'
import { t } from '../../app/i18n'
import type { ConfirmPrompt } from './confirm'
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
// cancels, focus returns to the trigger). Text only: messages come from the
// server and are never rendered as HTML. While `busy` (an action started by
// the confirm is running) both buttons are disabled and the dialog stays.
// With a `prompt`, one labelled text input sits above the buttons: the
// confirm button stays disabled while its trimmed value is empty and Enter
// in the input confirms. The value is the host's (`value` / `update:value`).
const props = withDefaults(
defineProps<{
open: boolean
@@ -24,10 +29,16 @@ const props = withDefaults(
cancelLabel?: string
danger?: boolean
busy?: boolean
prompt?: ConfirmPrompt | null
value?: string
}>(),
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false, busy: false },
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false, busy: false, prompt: null, value: '' },
)
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
const emit = defineEmits<{ 'update:open': [open: boolean]; 'update:value': [value: string]; confirm: []; cancel: [] }>()
const dialogId = `confirm-${useId()}`
const promptInput = ref<HTMLInputElement | null>(null)
const promptEmpty = computed(() => !!props.prompt && props.value.trim() === '')
// Closing any other way than the confirm button (Esc, overlay, Anuluj) cancels.
function onOpenChange(open: boolean): void {
@@ -41,12 +52,26 @@ function onOpenChange(open: boolean): void {
}
function onConfirm(): void {
if (props.busy) {
if (props.busy || promptEmpty.value) {
return
}
emit('confirm')
emit('update:open', false)
}
function onInput(event: Event): void {
emit('update:value', (event.target as HTMLInputElement).value)
}
watch(
() => props.open && !!props.prompt,
async (prompted) => {
if (prompted) {
await nextTick()
promptInput.value?.focus()
}
},
)
</script>
<template>
@@ -61,6 +86,24 @@ function onConfirm(): void {
{{ title ?? t('backend::lang.form.confirm') }}
</AlertDialogTitle>
<AlertDialogDescription class="text-muted [overflow-wrap:anywhere]">{{ message }}</AlertDialogDescription>
<div v-if="prompt" class="flex flex-col gap-1.5" data-confirm-prompt>
<label :for="`${dialogId}-value`" class="text-[13px] font-semibold">{{ prompt.label }}</label>
<input
:id="`${dialogId}-value`"
ref="promptInput"
type="text"
autocomplete="off"
:maxlength="prompt.maxLength"
:placeholder="prompt.placeholder"
:aria-describedby="prompt.comment ? `${dialogId}-comment` : undefined"
:disabled="busy"
:value="value"
class="h-control w-full rounded-control border border-border-strong bg-surface px-3 text-text outline-none focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
@input="onInput"
@keydown.enter.prevent="onConfirm"
/>
<p v-if="prompt.comment" :id="`${dialogId}-comment`" class="text-[13px] text-muted">{{ prompt.comment }}</p>
</div>
<div class="flex gap-2.5">
<AlertDialogCancel
data-action="cancel"
@@ -72,7 +115,7 @@ function onConfirm(): void {
<button
type="button"
data-action="confirm"
:disabled="busy"
:disabled="busy || promptEmpty"
:aria-busy="busy ? 'true' : undefined"
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
class="inline-flex h-button flex-1 items-center justify-center gap-2 rounded-control font-semibold hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"

View File

@@ -2,18 +2,30 @@
// true on confirm and false on Anuluj, Esc or a second request. With an
// action, the dialog stays open and busy while the action runs after the
// confirm, and closes when it settles (a toolbar delete shows its progress
// on the confirm button).
// on the confirm button). A request with a `prompt` asks for one text value
// in the dialog; the caller reads it from `value` inside its action (the
// dialog is still open then) or right after `ask` resolves true.
import { ref } from 'vue'
export interface ConfirmPrompt {
label: string
placeholder?: string
comment?: string
maxLength: number
}
export interface ConfirmRequest {
message: string
confirmLabel?: string
danger?: boolean
prompt?: ConfirmPrompt
}
export function useConfirm() {
const request = ref<ConfirmRequest | null>(null)
const busy = ref(false)
/** The prompt's text while a prompted request is open; '' otherwise. */
const value = ref('')
let resolver: ((ok: boolean) => void) | null = null
let action: (() => Promise<unknown>) | null = null
@@ -27,6 +39,7 @@ export function useConfirm() {
function ask(next: ConfirmRequest, run?: () => Promise<unknown>): Promise<boolean> {
settle(false)
value.value = ''
request.value = next
action = run ?? null
return new Promise<boolean>((resolve) => {
@@ -38,6 +51,9 @@ export function useConfirm() {
if (busy.value) {
return
}
if (request.value?.prompt && value.value.trim() === '') {
return
}
const run = action
if (run) {
busy.value = true
@@ -57,5 +73,9 @@ export function useConfirm() {
settle(false)
}
return { request, busy, ask, confirm, cancel }
function setValue(next: string): void {
value.value = next
}
return { request, busy, value, ask, confirm, cancel, setValue }
}

View File

@@ -7,6 +7,7 @@ import { t } from '../app/i18n'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import { controlClass } from '../components/form/control'
import { fieldErrors, focusField } from '../components/form/formState'
import AccountContributions from '../components/account/AccountContributions.vue'
import ApiTokensPanel from '../components/account/ApiTokensPanel.vue'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
@@ -411,6 +412,8 @@ async function onSave(): Promise<void> {
<ApiTokensPanel :confirm="confirm" />
<AccountContributions :confirm="confirm" />
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"