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:
Jakub Zych
2026-10-04 23:28:30 +02:00
parent ca9e9c0557
commit a879d6388c
45 changed files with 2007 additions and 38 deletions

View File

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

View File

@@ -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. */

View 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>

View File

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

View File

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