feat(12.1-01): declared record actions with an applicability rule
- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
@@ -1376,6 +1376,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RecordAction": {
|
||||
"properties": {
|
||||
"confirm": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.RecordEnvelope": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -1393,6 +1411,12 @@
|
||||
},
|
||||
"cabana.RecordMeta": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cabana.RecordAction"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
@@ -3803,6 +3827,7 @@
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"description": "meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
@@ -4014,6 +4039,140 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/{id}/actions/{action}": {
|
||||
"post": {
|
||||
"description": "Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
"in": "path",
|
||||
"name": "vendor",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Plugin",
|
||||
"in": "path",
|
||||
"name": "plugin",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Controller",
|
||||
"in": "path",
|
||||
"name": "controller",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Record id",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Record action name",
|
||||
"in": "path",
|
||||
"name": "action",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminActionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Empty object",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminActionResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Run a declared record action",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
|
||||
"get": {
|
||||
"description": "The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation.",
|
||||
|
||||
111
admin/src/api/schema.d.ts
vendored
111
admin/src/api/schema.d.ts
vendored
@@ -1601,7 +1601,10 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Show an admin record */
|
||||
/**
|
||||
* Show an admin record
|
||||
* @description meta.labels carries the display labels of relation fields. meta.actions lists the declared record actions the requesting admin may run and that apply to the record's current state; it is absent when none is offered.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1804,6 +1807,106 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/{id}/actions/{action}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Run a declared record action
|
||||
* @description Runs a record action the controller registers and the form's recordActions declares. The record is loaded and row-locked through the controller's form scope in one transaction (404 when missing or out of scope); an action that does not apply to the record's current state answers 409. The body must be {} and the answer's fill is always empty.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
/** @description Record id */
|
||||
id: number;
|
||||
/** @description Record action name */
|
||||
action: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Empty object */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminActionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminActionResult"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4516,11 +4619,17 @@ export interface components {
|
||||
"cabana.PartialView": {
|
||||
nodes: components["schemas"]["cabana.PartialNode"][];
|
||||
};
|
||||
"cabana.RecordAction": {
|
||||
confirm?: string;
|
||||
label: string;
|
||||
name: string;
|
||||
};
|
||||
"cabana.RecordEnvelope": {
|
||||
data: components["schemas"]["cabana.AdminRecord"];
|
||||
meta: components["schemas"]["cabana.RecordMeta"];
|
||||
};
|
||||
"cabana.RecordMeta": {
|
||||
actions?: components["schemas"]["cabana.RecordAction"][];
|
||||
labels: {
|
||||
[key: string]: components["schemas"]["cabana.RelationOption"][];
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
|
||||
export type BulkResult = Schemas['cabana.BulkResult']
|
||||
/** A checkbox action of a list: the built-in delete or a declared bulk action. */
|
||||
export type BulkAction = Schemas['cabana.BulkAction']
|
||||
/** A record action the record response offers: name, label and confirm text. */
|
||||
export type RecordAction = Schemas['cabana.RecordAction']
|
||||
/** 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. */
|
||||
|
||||
120
admin/src/components/form/RecordActions.vue
Normal file
120
admin/src/components/form/RecordActions.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { ControllerParams, RecordAction } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
import Button from '../ui/Button.vue'
|
||||
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
|
||||
// Record actions (UI-SPEC S2, D-10): one outline button per action the
|
||||
// record response offers, in the given order. The server already reduced the
|
||||
// list to what the admin may run and what applies to the record, so an action
|
||||
// that is not listed is not rendered. Every action asks for confirmation; the
|
||||
// dialog stays open and busy while the POST runs, and every button here is
|
||||
// disabled meanwhile. Labels, confirm texts and messages come from the server
|
||||
// and are rendered as text.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
source: ControllerParams
|
||||
recordId: number
|
||||
actions: RecordAction[]
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
/** true while a request runs, so the host can disable its other buttons. */
|
||||
busy: [running: boolean]
|
||||
/** The action ran: the toast text (server message or the default). */
|
||||
done: [text: string]
|
||||
/** 409: the action no longer applies; the host reloads the record. */
|
||||
stale: []
|
||||
/** 404: the record is gone or out of scope; the host shows its load failure. */
|
||||
gone: []
|
||||
}>()
|
||||
|
||||
const confirm = useConfirm()
|
||||
const running = ref(false)
|
||||
|
||||
async function run(action: RecordAction): Promise<void> {
|
||||
if (running.value || props.disabled) {
|
||||
return
|
||||
}
|
||||
let status = 0
|
||||
let ok = false
|
||||
let text = ''
|
||||
const confirmed = await confirm.ask(
|
||||
{
|
||||
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
|
||||
confirmLabel: action.label,
|
||||
danger: false,
|
||||
},
|
||||
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: {},
|
||||
})
|
||||
status = result.response.status
|
||||
ok = !!result.data
|
||||
text = result.data?.data.message ?? result.error?.error.message ?? ''
|
||||
} catch {
|
||||
status = 0
|
||||
} finally {
|
||||
running.value = false
|
||||
emit('busy', false)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
if (ok) {
|
||||
emit('done', text || t('backend::lang.form.action_done'))
|
||||
return
|
||||
}
|
||||
if (status === 409) {
|
||||
showToast(t('backend::lang.form.action_stale'), 'danger')
|
||||
emit('stale')
|
||||
return
|
||||
}
|
||||
if (status === 404) {
|
||||
emit('gone')
|
||||
return
|
||||
}
|
||||
if (status === 403) {
|
||||
showToast(text || t('backend::lang.list.action_forbidden'), 'danger')
|
||||
return
|
||||
}
|
||||
showToast(text || t('backend::lang.extension.action_failed'), 'danger')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="actions.length > 0">
|
||||
<Button
|
||||
v-for="action in actions"
|
||||
:key="action.name"
|
||||
variant="outline"
|
||||
size="md"
|
||||
:data-record-action="action.name"
|
||||
:disabled="disabled || running"
|
||||
@click="run(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
:busy="confirm.busy.value"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
10
admin/tests/fixtures/roster.record.json
vendored
Normal file
10
admin/tests/fixtures/roster.record.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
||||
"meta": {
|
||||
"labels": {},
|
||||
"actions": [
|
||||
{ "name": "activate", "label": "Activate" },
|
||||
{ "name": "reinstate", "label": "Reinstate", "confirm": "Lift the ban on this person?" }
|
||||
]
|
||||
}
|
||||
}
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -19,6 +19,7 @@ import listSchemaJson from './widgets.list-schema.json'
|
||||
import optionsJson from './widgets.options.json'
|
||||
import rosterListJson from './roster.list.json'
|
||||
import rosterListSchemaJson from './roster.list-schema.json'
|
||||
import rosterRecordJson from './roster.record.json'
|
||||
import recordJson from './widgets.record.json'
|
||||
import candidatesJson from './widgets.relation-candidates.json'
|
||||
import linkedJson from './widgets.relation-linked.json'
|
||||
@@ -58,6 +59,8 @@ export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelop
|
||||
export const listFixture: Rows = listJson
|
||||
/** A people list with declared bulk actions (Phase 12.1). */
|
||||
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
||||
/** One person with two offered record actions (Phase 12.1). */
|
||||
export const rosterRecordFixture: S['cabana.RecordEnvelope'] = rosterRecordJson
|
||||
/** The people of the roster list (Phase 12.1). */
|
||||
export const rosterListFixture: Rows = rosterListJson
|
||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
|
||||
// (UI-SPEC S1, D-09). Fixtures are neutral acme.roster.* data; no application
|
||||
// (UI-SPEC S1, D-09) and the record action buttons (UI-SPEC S2, D-10).
|
||||
// Fixtures are neutral acme.roster.* data; no application
|
||||
// names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
import RecordActions from '../../src/components/form/RecordActions.vue'
|
||||
import ToastHost from '../../src/components/ui/Toast.vue'
|
||||
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
|
||||
@@ -22,6 +25,11 @@ const strings = {
|
||||
},
|
||||
'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' },
|
||||
'backend::lang.extension.action_failed': { other: 'The action could not be completed. Please try again.' },
|
||||
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
|
||||
'backend::lang.form.action_done': { other: 'Action completed.' },
|
||||
'backend::lang.form.action_stale': {
|
||||
other: 'This action no longer applies to this record. The page has been refreshed.',
|
||||
},
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
@@ -315,3 +323,141 @@ describe('bulk actions menu (UI-SPEC S1, D-09)', () => {
|
||||
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe(label)
|
||||
})
|
||||
})
|
||||
|
||||
describe('record actions (UI-SPEC S2, D-10)', () => {
|
||||
const RECORD = `${LIST}/1`
|
||||
const source = { vendor: 'acme', plugin: 'roster', controller: 'people' }
|
||||
const offered = rosterRecordFixture.meta.actions ?? []
|
||||
const done = (message: string): Reply => ({ body: { data: { message, fill: {} }, meta: {} } })
|
||||
|
||||
function mountActions(routes: Record<string, Route>, props: { actions?: typeof offered; disabled?: boolean } = {}) {
|
||||
const calls = mockApi(routes)
|
||||
const wrapper = mount(
|
||||
{
|
||||
components: { RecordActions, ToastHost },
|
||||
props: ['actions', 'disabled'],
|
||||
emits: ['busy', 'done', 'stale', 'gone'],
|
||||
template: `<div>
|
||||
<RecordActions :source="source" :record-id="1" :actions="actions" :disabled="disabled"
|
||||
@busy="(v) => $emit('busy', v)" @done="(v) => $emit('done', v)" @stale="$emit('stale')" @gone="$emit('gone')" />
|
||||
<ToastHost />
|
||||
</div>`,
|
||||
setup: () => ({ source }),
|
||||
},
|
||||
{ props: { actions: props.actions ?? offered, disabled: props.disabled ?? false }, attachTo: document.body },
|
||||
)
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
const button = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-record-action="${name}"]`)
|
||||
|
||||
it('renders one outline button per offered action, in order, and nothing without actions', () => {
|
||||
const { wrapper } = mountActions({})
|
||||
const buttons = wrapper.findAll('[data-record-action]')
|
||||
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['activate', 'reinstate'])
|
||||
expect(buttons.map((item) => item.text())).toEqual(['Activate', 'Reinstate'])
|
||||
expect(buttons.every((item) => item.classes().includes('border-border-strong') && item.classes().includes('whitespace-nowrap'))).toBe(true)
|
||||
|
||||
const empty = mountActions({}, { actions: [] })
|
||||
expect(empty.wrapper.find('[data-record-action]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms, posts {} to the action route and emits done with the server message', async () => {
|
||||
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('The person was activated.') })
|
||||
await button(wrapper, 'activate').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog()!.textContent).toContain('Run “Activate” on this record?')
|
||||
const confirm = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
||||
expect(confirm.textContent?.trim()).toBe('Activate')
|
||||
expect(confirm.classList.contains('bg-primary')).toBe(true)
|
||||
await press('confirm')
|
||||
|
||||
const [post] = requestsTo(calls, 'POST', `${RECORD}/actions/activate`)
|
||||
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await post!.json()).toEqual({})
|
||||
expect(wrapper.emitted('done')).toEqual([['The person was activated.']])
|
||||
expect(wrapper.emitted('busy')).toEqual([[true], [false]])
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the action confirm text and the default done text, and sends nothing on cancel', async () => {
|
||||
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/reinstate`]: done('') })
|
||||
await button(wrapper, 'reinstate').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog()!.textContent).toContain('Lift the ban on this person?')
|
||||
await press('cancel')
|
||||
expect(requestsTo(calls, 'POST', `${RECORD}/actions/reinstate`)).toHaveLength(0)
|
||||
expect(wrapper.emitted('done')).toBeUndefined()
|
||||
|
||||
await button(wrapper, 'reinstate').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.emitted('done')).toEqual([['Action completed.']])
|
||||
})
|
||||
|
||||
it('disables every button and keeps the dialog busy while an action runs', async () => {
|
||||
let release: ((reply: Reply) => void) | undefined
|
||||
const { wrapper } = mountActions({
|
||||
[`POST ${RECORD}/actions/activate`]: () =>
|
||||
new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
})
|
||||
await button(wrapper, 'activate').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.getAttribute('aria-busy')).toBe('true')
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.disabled).toBe(true)
|
||||
expect(wrapper.emitted('busy')).toEqual([[true]])
|
||||
|
||||
release?.(done(''))
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') === undefined)).toBe(true)
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('emits stale with a toast on 409 and gone on 404', async () => {
|
||||
const { wrapper } = mountActions({
|
||||
[`POST ${RECORD}/actions/activate`]: error(409, 'conflict', 'Conflict'),
|
||||
[`POST ${RECORD}/actions/reinstate`]: error(404, 'not_found', 'Not found'),
|
||||
})
|
||||
await button(wrapper, 'activate').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.emitted('stale')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('This action no longer applies to this record.')
|
||||
|
||||
await button(wrapper, 'reinstate').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.emitted('gone')).toHaveLength(1)
|
||||
expect(wrapper.emitted('done')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('toasts a 403 and any other failure with the server message or the fallback', async () => {
|
||||
const { wrapper } = mountActions({
|
||||
[`POST ${RECORD}/actions/activate`]: error(403, 'forbidden', ''),
|
||||
[`POST ${RECORD}/actions/reinstate`]: error(500, 'error', 'The roster is locked.'),
|
||||
})
|
||||
await button(wrapper, 'activate').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.text()).toContain('You do not have permission to run this action.')
|
||||
|
||||
await button(wrapper, 'reinstate').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.text()).toContain('The roster is locked.')
|
||||
expect(wrapper.emitted('done')).toBeUndefined()
|
||||
expect(wrapper.emitted('stale')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing while the host disables it', async () => {
|
||||
const { wrapper } = mountActions({}, { disabled: true })
|
||||
expect(button(wrapper, 'activate').attributes('disabled')).toBeDefined()
|
||||
await button(wrapper, 'activate').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user