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

@@ -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"