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

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