feat(12.1-01): declared bulk actions on admin lists
- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
@@ -176,6 +176,9 @@
|
||||
},
|
||||
"cabana.BulkAction": {
|
||||
"properties": {
|
||||
"confirm": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -188,6 +191,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.BulkActionResult": {
|
||||
"properties": {
|
||||
"affected": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"affected",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.BulkResult": {
|
||||
"properties": {
|
||||
"deleted": {
|
||||
@@ -367,6 +385,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_BulkActionResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/cabana.BulkActionResult"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.Envelope-cabana_BulkResult": {
|
||||
"properties": {
|
||||
"data": {
|
||||
@@ -2727,6 +2760,131 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/bulk/{action}": {
|
||||
"post": {
|
||||
"description": "Runs a bulk action the controller registers and the list's bulkActions declares. The ids are resolved and row-locked through the controller's list scope in one transaction before the action runs: a selection that matches no scoped row answers affected 0 without running the action, and a partial selection is a 409 that changes nothing.",
|
||||
"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": "Bulk action name",
|
||||
"in": "path",
|
||||
"name": "action",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Record ids",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_BulkActionResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 bulk action",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||
"get": {
|
||||
"description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.",
|
||||
|
||||
107
admin/src/api/schema.d.ts
vendored
107
admin/src/api/schema.d.ts
vendored
@@ -862,6 +862,104 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/{vendor}/{plugin}/{controller}/bulk/{action}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Run a declared bulk action
|
||||
* @description Runs a bulk action the controller registers and the list's bulkActions declares. The ids are resolved and row-locked through the controller's list scope in one transaction before the action runs: a selection that matches no scoped row answers affected 0 without running the action, and a partial selection is a 409 that changes nothing.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Vendor */
|
||||
vendor: string;
|
||||
/** @description Plugin */
|
||||
plugin: string;
|
||||
/** @description Controller */
|
||||
controller: string;
|
||||
/** @description Bulk action name */
|
||||
action: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Record ids */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_BulkActionResult"];
|
||||
};
|
||||
};
|
||||
/** @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}/fields/{field}/options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4046,9 +4144,14 @@ export interface components {
|
||||
name: string;
|
||||
};
|
||||
"cabana.BulkAction": {
|
||||
confirm?: string;
|
||||
label?: string;
|
||||
name: string;
|
||||
};
|
||||
"cabana.BulkActionResult": {
|
||||
affected: number;
|
||||
message: string;
|
||||
};
|
||||
"cabana.BulkResult": {
|
||||
deleted: number;
|
||||
};
|
||||
@@ -4092,6 +4195,10 @@ export interface components {
|
||||
data: components["schemas"]["cabana.AdminRecord"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_BulkActionResult": {
|
||||
data: components["schemas"]["cabana.BulkActionResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
};
|
||||
"cabana.Envelope-cabana_BulkResult": {
|
||||
data: components["schemas"]["cabana.BulkResult"];
|
||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||
|
||||
@@ -34,6 +34,10 @@ export type FilterOption = Schemas['cabana.FilterOption']
|
||||
export type RecordMeta = Schemas['cabana.RecordMeta']
|
||||
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 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. */
|
||||
export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||
/** A widget or toolbar action result: the toast message and fill values. */
|
||||
|
||||
60
admin/src/components/list/BulkActionsMenu.vue
Normal file
60
admin/src/components/list/BulkActionsMenu.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { BulkAction } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
// Bulk actions menu (UI-SPEC S1, D-09): the declared bulk actions the server
|
||||
// already filtered to what the admin may run, in declared order. The built-in
|
||||
// delete is not listed here; it stays the toolbar button. Labels come from
|
||||
// the server and are rendered as text. The trigger keeps its label while
|
||||
// disabled (nothing selected, or an action running).
|
||||
defineProps<{
|
||||
actions: BulkAction[]
|
||||
disabled: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ select: [name: string] }>()
|
||||
|
||||
const trigger = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
/** Moves focus back to the trigger after the confirmation closes. */
|
||||
function focus(): void {
|
||||
const el = trigger.value?.$el
|
||||
if (el instanceof HTMLElement) {
|
||||
el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot :modal="false">
|
||||
<DropdownMenuTrigger as-child :disabled="disabled">
|
||||
<Button ref="trigger" variant="outline" size="md" data-action="bulk-actions" :disabled="disabled">
|
||||
{{ t('backend::lang.list.bulk_actions') }}
|
||||
<ChevronDown :size="16" class="text-muted" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-bulk-menu
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
class="z-50 flex w-[240px] max-w-[320px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="action in actions"
|
||||
:key="action.name"
|
||||
:data-bulk-action="action.name"
|
||||
class="flex min-h-10 cursor-pointer items-center rounded-control px-3 py-2 outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
|
||||
@select="emit('select', action.name)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Check, Search, Trash2 } from '@lucide/vue'
|
||||
import type { ToolbarAction } from '../../api/types'
|
||||
import type { BulkAction, ToolbarAction } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import Button from '../ui/Button.vue'
|
||||
import BulkActionsMenu from './BulkActionsMenu.vue'
|
||||
|
||||
// List toolbar (design screen 3, D-14): the search input and the toolbar
|
||||
// buttons in declared order. Delete is a disabled outline button without a
|
||||
@@ -11,6 +12,9 @@ import Button from '../ui/Button.vue'
|
||||
// A registered plugin action (D-12) is an outline button labelled from
|
||||
// `actions`, the list the server already filtered to what the admin may run;
|
||||
// it ignores the selection and is disabled and busy while its POST runs.
|
||||
// Declared bulk actions (D-09) sit in one menu directly after the selection
|
||||
// pill; the menu is not rendered without a permitted action, and its trigger
|
||||
// is disabled while nothing is selected or a bulk action runs.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showSearch: boolean
|
||||
@@ -22,12 +26,23 @@ const props = withDefaults(
|
||||
deleteLabel: string
|
||||
actions?: ToolbarAction[]
|
||||
busyAction?: string | null
|
||||
bulkActions?: BulkAction[]
|
||||
bulkBusy?: boolean
|
||||
}>(),
|
||||
{ actions: () => [], busyAction: null },
|
||||
{ actions: () => [], busyAction: null, bulkActions: () => [], bulkBusy: false },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string] }>()
|
||||
const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string]; bulk: [name: string] }>()
|
||||
|
||||
const labels = computed(() => new Map(props.actions.map((action) => [action.name, action.label])))
|
||||
|
||||
const bulkMenu = ref<InstanceType<typeof BulkActionsMenu> | null>(null)
|
||||
|
||||
/** Moves focus to the bulk menu trigger (after its confirmation closes). */
|
||||
function focusBulk(): void {
|
||||
bulkMenu.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focusBulk })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,7 +60,7 @@ const labels = computed(() => new Map(props.actions.map((action) => [action.name
|
||||
/>
|
||||
</label>
|
||||
<span v-else />
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2.5">
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
data-selected-pill
|
||||
@@ -53,6 +68,13 @@ const labels = computed(() => new Map(props.actions.map((action) => [action.name
|
||||
>
|
||||
<Check :size="14" aria-hidden="true" />{{ selectedLabel }}
|
||||
</span>
|
||||
<BulkActionsMenu
|
||||
v-if="bulkActions.length > 0"
|
||||
ref="bulkMenu"
|
||||
:actions="bulkActions"
|
||||
:disabled="selectedCount === 0 || bulkBusy"
|
||||
@select="(name: string) => emit('bulk', name)"
|
||||
/>
|
||||
<template v-for="button in buttons" :key="button">
|
||||
<Button
|
||||
v-if="button === 'delete'"
|
||||
|
||||
@@ -3,9 +3,9 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Plus, SearchX } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, ListMeta, ListQuery, ListSchema } from '../api/types'
|
||||
import type { AdminRecord, BulkActionResult, ListMeta, ListQuery, ListSchema } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { message, t } from '../app/i18n'
|
||||
import { message, t, tc } from '../app/i18n'
|
||||
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
||||
import { loadControllerAssets } from '../app/pluginAssets'
|
||||
import { mapWinterUrl } from '../app/winterUrl'
|
||||
@@ -78,6 +78,11 @@ const toolbarButtons = computed(() =>
|
||||
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
|
||||
)
|
||||
const busyAction = ref<string | null>(null)
|
||||
// Declared bulk actions the admin may run (D-09), in declared order. The
|
||||
// built-in delete stays the toolbar button and is not part of the menu.
|
||||
const bulkActions = computed(() => (schema.value?.bulkActions ?? []).filter((action) => action.name !== 'delete'))
|
||||
const bulkBusy = ref(false)
|
||||
const toolbar = ref<InstanceType<typeof ListToolbar> | null>(null)
|
||||
|
||||
const perPageOptions = computed(() => {
|
||||
const options = schema.value?.perPageOptions ?? []
|
||||
@@ -227,6 +232,72 @@ async function onDelete(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a declared bulk action on the selected rows (UI-SPEC S1). The confirm
|
||||
* dialog stays open and busy while the POST runs; the outcome is handled
|
||||
* after it closes and focus is back on the menu trigger.
|
||||
*/
|
||||
async function onBulkAction(name: string): Promise<void> {
|
||||
const action = bulkActions.value.find((item) => item.name === name)
|
||||
const ids = selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||
if (!action || ids.length === 0 || bulkBusy.value) {
|
||||
return
|
||||
}
|
||||
const label = action.label ?? name
|
||||
let status = 0
|
||||
let done: BulkActionResult | null = null
|
||||
let failure = ''
|
||||
const ok = await confirm.ask(
|
||||
{
|
||||
message: action.confirm || tc('backend::lang.list.bulk_confirm', ids.length, { action: label }),
|
||||
confirmLabel: label,
|
||||
danger: false,
|
||||
},
|
||||
async () => {
|
||||
bulkBusy.value = true
|
||||
try {
|
||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk/{action}', {
|
||||
params: { path: { ...path, action: name } },
|
||||
body: { ids },
|
||||
})
|
||||
status = result.response.status
|
||||
done = result.data?.data ?? null
|
||||
failure = result.error?.error.message ?? ''
|
||||
} catch {
|
||||
status = 0
|
||||
} finally {
|
||||
bulkBusy.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
// Reka restores focus on its own timer when the dialog closes; the trigger
|
||||
// takes it afterwards, while the selection still keeps it enabled.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
toolbar.value?.focusBulk()
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
const result = done as BulkActionResult | null
|
||||
if (result) {
|
||||
showToast(result.message || tc('backend::lang.list.bulk_done', result.affected))
|
||||
selected.value = []
|
||||
await loadList()
|
||||
partialReload.value += 1
|
||||
return
|
||||
}
|
||||
if (status === 409) {
|
||||
showToast(t('backend::lang.list.bulk_stale'), 'danger')
|
||||
selected.value = []
|
||||
await loadList()
|
||||
return
|
||||
}
|
||||
if (status === 403) {
|
||||
showToast(failure || t('backend::lang.list.action_forbidden'), 'danger')
|
||||
return
|
||||
}
|
||||
showToast(failure || t('backend::lang.extension.action_failed'), 'danger')
|
||||
}
|
||||
|
||||
/** Runs a registered toolbar action (D-12): no confirmation, body {}. */
|
||||
async function onAction(name: string): Promise<void> {
|
||||
if (busyAction.value !== null) {
|
||||
@@ -285,6 +356,7 @@ async function onAction(name: string): Promise<void> {
|
||||
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
|
||||
<ListToolbar
|
||||
v-if="schema"
|
||||
ref="toolbar"
|
||||
:show-search="schema.showSearch"
|
||||
:search="searchText"
|
||||
:search-prompt="message(messages?.searchPrompt)"
|
||||
@@ -294,9 +366,12 @@ async function onAction(name: string): Promise<void> {
|
||||
:delete-label="message(messages?.deleteSelected, selected.length)"
|
||||
:actions="toolbarActions"
|
||||
:busy-action="busyAction"
|
||||
:bulk-actions="bulkActions"
|
||||
:bulk-busy="bulkBusy"
|
||||
@update:search="onSearch"
|
||||
@delete="onDelete"
|
||||
@action="onAction"
|
||||
@bulk="onBulkAction"
|
||||
/>
|
||||
<FilterBar
|
||||
v-if="schema && schema.filters.length > 0"
|
||||
@@ -351,6 +426,7 @@ async function onAction(name: string): Promise<void> {
|
||||
: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"
|
||||
/>
|
||||
|
||||
41
admin/tests/fixtures/roster.list-schema.json
vendored
Normal file
41
admin/tests/fixtures/roster.list-schema.json
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"data": {
|
||||
"title": "People",
|
||||
"recordsPerPage": 20,
|
||||
"perPageOptions": [],
|
||||
"showSearch": true,
|
||||
"showSetup": false,
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "search",
|
||||
"recordUrl": "acme/roster/people/update/:id",
|
||||
"toolbarButtons": ["create", "delete"],
|
||||
"toolbarActions": [],
|
||||
"assets": { "scripts": [], "styles": [] },
|
||||
"columns": [
|
||||
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||
{ "key": "email", "label": "Email", "searchable": true, "sortable": true }
|
||||
],
|
||||
"filters": [],
|
||||
"rowActions": [],
|
||||
"bulkActions": [
|
||||
{ "name": "delete", "label": "Delete selected" },
|
||||
{ "name": "activate", "label": "Activate", "confirm": "Activate the selected people?" },
|
||||
{ "name": "archive", "label": "Archive" }
|
||||
],
|
||||
"messages": {
|
||||
"create": { "other": "New person" },
|
||||
"deleteConfirm": { "other": "Delete the selected (:count)?" },
|
||||
"deleteSelected": { "other": "Delete selected" },
|
||||
"deleted": { "one": "Deleted :count record", "other": "Deleted :count records" },
|
||||
"empty": { "other": "There are no records in this view." },
|
||||
"emptySearch": { "other": "Nothing found" },
|
||||
"emptySearchHint": { "other": "No record matches “:term”. Try another search." },
|
||||
"recordCount": { "one": ":count record", "other": ":count records" },
|
||||
"searchPrompt": { "other": "Search…" },
|
||||
"selected": { "other": ":count selected" }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
8
admin/tests/fixtures/roster.list.json
vendored
Normal file
8
admin/tests/fixtures/roster.list.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
||||
{ "id": 2, "name": "Bob Stone", "email": "bob@example.test" },
|
||||
{ "id": 3, "name": "Cy Young", "email": "cy@example.test" }
|
||||
],
|
||||
"meta": { "page": 1, "per_page": 20, "total": 3, "last_page": 1 }
|
||||
}
|
||||
6
admin/tests/fixtures/typed.ts
vendored
6
admin/tests/fixtures/typed.ts
vendored
@@ -17,6 +17,8 @@ import formSchemaJson from './widgets.form-schema.json'
|
||||
import listJson from './widgets.list.json'
|
||||
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 recordJson from './widgets.record.json'
|
||||
import candidatesJson from './widgets.relation-candidates.json'
|
||||
import linkedJson from './widgets.relation-linked.json'
|
||||
@@ -54,6 +56,10 @@ export const extensionListSchemaFixture: S['cabana.Envelope-cabana_ListSchema']
|
||||
*/
|
||||
export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelope-cabana_PartialView']
|
||||
export const listFixture: Rows = listJson
|
||||
/** A people list with declared bulk actions (Phase 12.1). */
|
||||
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
||||
/** The people of the roster list (Phase 12.1). */
|
||||
export const rosterListFixture: Rows = rosterListJson
|
||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||
export const optionsFixture: {
|
||||
maker: S['cabana.Envelope-array_cabana_FilterOption']
|
||||
|
||||
317
admin/tests/smoke/actions.smoke.test.ts
Normal file
317
admin/tests/smoke/actions.smoke.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
// 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
|
||||
// names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, 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'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
|
||||
// The framework strings these screens use, as GET /lang serves them in en.
|
||||
const strings = {
|
||||
'backend::lang.list.bulk_actions': { other: 'Bulk actions' },
|
||||
'backend::lang.list.bulk_confirm': { other: 'Run “:action” on the selected (:count)?' },
|
||||
'backend::lang.list.bulk_done': {
|
||||
one: 'Action completed for :count record.',
|
||||
other: 'Action completed for :count records.',
|
||||
},
|
||||
'backend::lang.list.bulk_stale': {
|
||||
other: 'Some of the selected records are no longer available. The list has been refreshed; select the records again.',
|
||||
},
|
||||
'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.' },
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
||||
[`GET ${LIST}`]: { body: rosterListFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function schemaWith(bulkActions: (typeof rosterListSchemaFixture)['data']['bulkActions']) {
|
||||
const schema = clone(rosterListSchemaFixture)
|
||||
schema.data.bulkActions = bulkActions
|
||||
return schema
|
||||
}
|
||||
|
||||
const error = (status: number, code: string, message: string): Reply => ({
|
||||
status,
|
||||
body: { error: { code, message, details: {} } },
|
||||
})
|
||||
|
||||
const trigger = (wrapper: VueWrapper) => wrapper.find('[data-action="bulk-actions"]')
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
const pill = (wrapper: VueWrapper) => wrapper.find('[data-selected-pill]')
|
||||
const listGets = (calls: Request[]) => requestsTo(calls, 'GET', LIST).length
|
||||
|
||||
async function selectRows(wrapper: VueWrapper, count: number): Promise<void> {
|
||||
const boxes = wrapper.findAll('td[data-select] [role="checkbox"]')
|
||||
for (const box of boxes.slice(0, count)) {
|
||||
await box.trigger('click')
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the menu and chooses one action; the confirmation is then open. */
|
||||
async function choose(wrapper: VueWrapper, name: string): Promise<void> {
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const item = document.body.querySelector<HTMLElement>(`[data-bulk-menu] [data-bulk-action="${name}"]`)
|
||||
expect(item).not.toBeNull()
|
||||
item!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function press(action: 'confirm' | 'cancel'): Promise<void> {
|
||||
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** Lets the dialog close and the outcome (toast, reload) settle. */
|
||||
async function settle(): Promise<void> {
|
||||
await wait(10)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('bulk actions menu (UI-SPEC S1, D-09)', () => {
|
||||
it('keeps a labelled, disabled trigger until rows are selected', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(trigger(wrapper).find('svg').exists()).toBe(true)
|
||||
// The menu sits directly after the selection pill and before delete.
|
||||
await selectRows(wrapper, 1)
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
|
||||
const order = wrapper.findAll('[data-selected-pill], [data-action="bulk-actions"], [data-action="delete"]')
|
||||
expect(order.map((node) => node.attributes('data-action') ?? 'pill')).toEqual(['pill', 'bulk-actions', 'delete'])
|
||||
})
|
||||
|
||||
it('lists the declared actions in order, without delete, icons or separators', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||
await selectRows(wrapper, 2)
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const menu = document.body.querySelector('[data-bulk-menu]')!
|
||||
expect(menu.getAttribute('role')).toBe('menu')
|
||||
const items = Array.from(menu.querySelectorAll<HTMLElement>('[data-bulk-action]'))
|
||||
expect(items.map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive'])
|
||||
expect(items.map((item) => item.textContent?.trim())).toEqual(['Activate', 'Archive'])
|
||||
expect(items.every((item) => item.classList.contains('min-h-10') && !item.classList.contains('truncate'))).toBe(true)
|
||||
expect(menu.querySelector('svg, [role="separator"], [role="group"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('confirms with the action text, posts the selected ids and clears the selection', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`POST ${LIST}/bulk/activate`]: { body: { data: { message: '', affected: 2 }, meta: {} } } }),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
|
||||
expect(dialog()!.textContent).toContain('Activate the selected people?')
|
||||
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')
|
||||
await settle()
|
||||
|
||||
const [post] = requestsTo(calls, 'POST', `${LIST}/bulk/activate`)
|
||||
expect(new URL(post!.url).pathname.endsWith('/bulk/activate')).toBe(true)
|
||||
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await post!.json()).toEqual({ ids: [1, 2] })
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Action completed for 2 records.')
|
||||
expect(pill(wrapper).exists()).toBe(false)
|
||||
expect(listGets(calls)).toBe(gets + 1)
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the default confirm text and shows the server message and the singular count', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/archive`]: { body: { data: { message: 'The selected people were archived.', affected: 2 }, meta: {} } },
|
||||
[`POST ${LIST}/bulk/activate`]: { body: { data: { message: '', affected: 1 }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'archive')
|
||||
expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('The selected people were archived.')
|
||||
|
||||
// The server's affected count may be lower than the selection.
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.text()).toContain('Action completed for 1 record.')
|
||||
})
|
||||
|
||||
it('keeps the dialog open and busy and the trigger disabled while the request runs', async () => {
|
||||
let release: ((reply: Reply) => void) | undefined
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/activate`]: () =>
|
||||
new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 1)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
|
||||
const confirm = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
||||
const cancel = dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(cancel.disabled).toBe(true)
|
||||
expect(confirm.getAttribute('aria-busy')).toBe('true')
|
||||
expect(confirm.querySelector('svg')).not.toBeNull()
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||
|
||||
release?.({ body: { data: { message: '', affected: 1 }, meta: {} } })
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('sends nothing on cancel and returns focus to the trigger', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||
await selectRows(wrapper, 1)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('cancel')
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'POST', `${LIST}/bulk/activate`)).toHaveLength(0)
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||
})
|
||||
|
||||
it('returns focus to the trigger after a confirmed run', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`POST ${LIST}/bulk/activate`]: error(500, 'error', 'The roster is locked.') }),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 1)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||
})
|
||||
|
||||
it('toasts a 409, clears the selection and reloads the list', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`POST ${LIST}/bulk/activate`]: error(409, 'conflict', 'Conflict') }),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Some of the selected records are no longer available.')
|
||||
expect(pill(wrapper).exists()).toBe(false)
|
||||
expect(listGets(calls)).toBe(gets + 1)
|
||||
})
|
||||
|
||||
it('toasts a 403 with the server message or the fallback and keeps the selection', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/activate`]: error(403, 'forbidden', ''),
|
||||
[`POST ${LIST}/bulk/archive`]: error(403, 'forbidden', 'Only a manager may archive people.'),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
|
||||
await choose(wrapper, 'archive')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.text()).toContain('Only a manager may archive people.')
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
expect(listGets(calls)).toBe(gets)
|
||||
})
|
||||
|
||||
it('toasts any other failure, keeps the selection and reloads nothing', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/activate`]: error(500, 'error', ''),
|
||||
[`POST ${LIST}/bulk/archive`]: error(422, 'validation_failed', 'One of the people cannot be archived.'),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('The action could not be completed. Please try again.')
|
||||
|
||||
await choose(wrapper, 'archive')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.text()).toContain('One of the people cannot be archived.')
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
expect(listGets(calls)).toBe(gets)
|
||||
})
|
||||
|
||||
it('renders no menu when the admin may run no declared action', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith([{ name: 'delete', label: 'Delete selected' }]) } }),
|
||||
{ attach: true },
|
||||
)
|
||||
expect(trigger(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a label with markup characters as text', async () => {
|
||||
const label = '<b onclick="x()">Activate</b> & a label long enough to wrap inside the menu'
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith([{ name: 'activate', label }]) } }),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 1)
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const menuItem = document.body.querySelector<HTMLElement>('[data-bulk-action="activate"]')!
|
||||
expect(menuItem.querySelector('b')).toBeNull()
|
||||
expect(menuItem.textContent?.trim()).toBe(label)
|
||||
menuItem.click()
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('b')).toBeNull()
|
||||
expect(dialog()!.textContent).toContain(`Run “${label}” on the selected (1)?`)
|
||||
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe(label)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user