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:
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'"
|
||||
|
||||
Reference in New Issue
Block a user