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

@@ -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)
})
})

View 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([])
})
})