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