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:
@@ -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. */
|
||||
|
||||
126
admin/src/components/account/AccountContributions.vue
Normal file
126
admin/src/components/account/AccountContributions.vue
Normal 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>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 ?? ''"
|
||||
|
||||
125
admin/tests/account/AccountContributions.test.ts
Normal file
125
admin/tests/account/AccountContributions.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// My account sections plugins contribute (phase 19.7, D-04 part 1).
|
||||
// Fixtures are neutral acme.* data; no application names appear in
|
||||
// framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, profile, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const CONTRIBUTIONS = `${API}/account/contributions`
|
||||
const OPEN = `${CONTRIBUTIONS}/site_link/actions/open`
|
||||
|
||||
const strings = {
|
||||
'backend::lang.form.action_stale': { other: 'This action no longer applies.' },
|
||||
'backend::lang.form.action_done': { other: 'Action completed.' },
|
||||
}
|
||||
|
||||
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
|
||||
|
||||
const sections = [
|
||||
{
|
||||
name: 'site_link',
|
||||
label: 'Site account',
|
||||
description: 'Link the site account you sign in with.',
|
||||
status: 'Not linked',
|
||||
actions: [{ name: 'open', label: 'Link account', confirm: '', danger: false }],
|
||||
},
|
||||
{
|
||||
name: 'newsletter',
|
||||
label: 'Newsletter digest',
|
||||
description: '',
|
||||
status: 'Weekly',
|
||||
actions: [{ name: 'stop', label: 'Stop the digest', confirm: 'Stop the weekly digest?', danger: true }],
|
||||
},
|
||||
]
|
||||
|
||||
async function openAccount(routes: Record<string, Route> = {}) {
|
||||
return mountApp(
|
||||
'/account',
|
||||
{
|
||||
[`GET ${API}/auth/me`]: envelope(profile.data),
|
||||
[`GET ${API}/account/tokens`]: envelope([]),
|
||||
[`GET ${CONTRIBUTIONS}`]: envelope(sections),
|
||||
...routes,
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('My account contributions', () => {
|
||||
it('renders one card per section with its label, description, status and actions', async () => {
|
||||
const { wrapper } = await openAccount()
|
||||
const cards = wrapper.findAll('[data-account-contribution]')
|
||||
expect(cards.map((card) => card.attributes('data-account-contribution'))).toEqual(['site_link', 'newsletter'])
|
||||
expect(cards[0]!.find('h2').text()).toBe('Site account')
|
||||
expect(cards[0]!.text()).toContain('Link the site account you sign in with.')
|
||||
expect(wrapper.findAll('[data-account-contribution-status]').map((status) => status.text())).toEqual(['Not linked', 'Weekly'])
|
||||
expect(cards[1]!.find('[data-account-contribution-action="stop"]').text()).toBe('Stop the digest')
|
||||
// Text, never HTML.
|
||||
expect(wrapper.find('[data-account-contribution]').html()).not.toContain('v-html')
|
||||
})
|
||||
|
||||
it('renders no card when no plugin contributes', async () => {
|
||||
const { wrapper } = await openAccount({ [`GET ${CONTRIBUTIONS}`]: envelope([]) })
|
||||
expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0)
|
||||
expect(wrapper.find('[data-api-tokens]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('an action without a confirmation posts at once, shows the toast, opens and renders the URL and reloads the list', async () => {
|
||||
const opened = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const { wrapper, calls } = await openAccount({
|
||||
[`POST ${OPEN}`]: envelope({ message: 'Open the site to finish linking.', url: 'https://blog.example.com/link-admin?code=abc' }),
|
||||
})
|
||||
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', OPEN)).toHaveLength(1)
|
||||
expect(requestsTo(calls, 'POST', OPEN)[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Open the site to finish linking.', 'success']])
|
||||
expect(opened).toHaveBeenCalledWith('https://blog.example.com/link-admin?code=abc', '_blank', 'noopener')
|
||||
const link = wrapper.find('[data-account-contribution-url]')
|
||||
expect(link.attributes('href')).toBe('https://blog.example.com/link-admin?code=abc')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
expect(link.attributes('rel')).toBe('noopener noreferrer')
|
||||
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(2)
|
||||
expect(document.body.querySelector('[data-confirm-dialog]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a danger action asks first and posts on confirm', async () => {
|
||||
const STOP = `${CONTRIBUTIONS}/newsletter/actions/stop`
|
||||
const { wrapper, calls } = await openAccount({ [`POST ${STOP}`]: envelope({ message: 'Stopped.', url: '' }) })
|
||||
await wrapper.find('[data-account-contribution-action="stop"]').trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
expect(dialog?.textContent).toContain('Stop the weekly digest?')
|
||||
expect(requestsTo(calls, 'POST', STOP)).toHaveLength(0)
|
||||
dialog!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', STOP)).toHaveLength(1)
|
||||
expect(toasts.value.map((toast) => toast.text)).toEqual(['Stopped.'])
|
||||
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('409 shows the stale toast and refetches the list', async () => {
|
||||
const { wrapper, calls } = await openAccount({
|
||||
[`POST ${OPEN}`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
|
||||
})
|
||||
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies.', 'danger']])
|
||||
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
140
admin/tests/form/RecordActionsPrompt.test.ts
Normal file
140
admin/tests/form/RecordActionsPrompt.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Prompted record actions (phase 19.7, D-34, planner decision P-01): the
|
||||
// confirm dialog asks for one text value and posts it as `payload`; an
|
||||
// action without a prompt posts an empty body as before.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import RecordActions from '../../src/components/form/RecordActions.vue'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import type { RecordAction } from '../../src/api/types'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const RECORD = `${API}/acme/roster/people/1`
|
||||
const LINK = `${RECORD}/actions/link`
|
||||
const PING = `${RECORD}/actions/ping`
|
||||
|
||||
const strings = {
|
||||
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
|
||||
'backend::lang.form.action_done': { other: 'Action completed.' },
|
||||
}
|
||||
|
||||
const prompted: RecordAction = {
|
||||
name: 'link',
|
||||
label: 'Link site account',
|
||||
prompt: { label: 'Site login', placeholder: 'login', comment: 'The login of the site account.', max_length: 32 },
|
||||
}
|
||||
const plain: RecordAction = { name: 'ping', label: 'Ping' }
|
||||
|
||||
const ok = { body: { data: { message: 'Linked.', fill: {} }, meta: {} } }
|
||||
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
const promptInput = () => dialog()?.querySelector<HTMLInputElement>('[data-confirm-prompt] input') ?? null
|
||||
const confirmButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
||||
|
||||
function mountActions(routes: Record<string, Route>, actions: RecordAction[] = [prompted, plain]) {
|
||||
const calls = mockApi(routes)
|
||||
const done: string[] = []
|
||||
const wrapper = mount(RecordActions, {
|
||||
props: { source: { vendor: 'acme', plugin: 'roster', controller: 'people' }, recordId: 1, actions, onDone: (text: string) => done.push(text) },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, calls, done }
|
||||
}
|
||||
|
||||
async function click(wrapper: VueWrapper, name: string): Promise<void> {
|
||||
await wrapper.find(`[data-record-action="${name}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function type(value: string): Promise<void> {
|
||||
const input = promptInput()!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function bodyOf(request: Request): Promise<unknown> {
|
||||
return request.clone().json()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('prompted record actions', () => {
|
||||
it('renders the input with its label, placeholder, comment and maxlength, and keeps confirm disabled until a value is typed', async () => {
|
||||
const { wrapper, calls } = mountActions({ [`POST ${LINK}`]: ok })
|
||||
await click(wrapper, 'link')
|
||||
const input = promptInput()
|
||||
expect(input).not.toBeNull()
|
||||
expect(input!.getAttribute('maxlength')).toBe('32')
|
||||
expect(input!.getAttribute('placeholder')).toBe('login')
|
||||
expect(input!.getAttribute('autocomplete')).toBe('off')
|
||||
expect(dialog()!.querySelector(`label[for="${input!.id}"]`)?.textContent).toBe('Site login')
|
||||
expect(document.getElementById(input!.getAttribute('aria-describedby')!)?.textContent).toBe('The login of the site account.')
|
||||
expect(confirmButton().disabled).toBe(true)
|
||||
await type(' ')
|
||||
expect(confirmButton().disabled).toBe(true)
|
||||
await type('acme')
|
||||
expect(confirmButton().disabled).toBe(false)
|
||||
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('posts the trimmed value as payload and emits done', async () => {
|
||||
const { wrapper, calls, done } = mountActions({ [`POST ${LINK}`]: ok })
|
||||
await click(wrapper, 'link')
|
||||
await type(' acme ')
|
||||
confirmButton().click()
|
||||
await flushPromises()
|
||||
const posts = requestsTo(calls, 'POST', LINK)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(await bodyOf(posts[0]!)).toEqual({ payload: 'acme' })
|
||||
expect(done).toEqual(['Linked.'])
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('Enter in the input confirms', async () => {
|
||||
const { wrapper, calls } = mountActions({ [`POST ${LINK}`]: ok })
|
||||
await click(wrapper, 'link')
|
||||
await type('acme')
|
||||
promptInput()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an action without a prompt renders no input and posts an empty body', async () => {
|
||||
const { wrapper, calls } = mountActions({ [`POST ${PING}`]: ok })
|
||||
await click(wrapper, 'ping')
|
||||
expect(promptInput()).toBeNull()
|
||||
expect(confirmButton().disabled).toBe(false)
|
||||
confirmButton().click()
|
||||
await flushPromises()
|
||||
const posts = requestsTo(calls, 'POST', PING)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(await bodyOf(posts[0]!)).toEqual({})
|
||||
})
|
||||
|
||||
it("a 422 on payload shows the server's detail as a danger toast and leaves the dialog closed", async () => {
|
||||
const { wrapper, done } = mountActions({
|
||||
[`POST ${LINK}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { payload: ['The value may not be longer than 32 characters.'] } } },
|
||||
},
|
||||
})
|
||||
await click(wrapper, 'link')
|
||||
await type('acme')
|
||||
confirmButton().click()
|
||||
await flushPromises()
|
||||
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The value may not be longer than 32 characters.', 'danger']])
|
||||
expect(dialog()).toBeNull()
|
||||
expect(done).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user