feat(10.1-02): run registered toolbar actions and scope plugin CSS per controller
- ListToolbar renders server-filtered actions after delete as outline buttons, busy during their POST
- ListView posts {} to toolbar/{action}, toasts, reloads the list and refetches the header partial
- Lists load controller assets too, so other controllers' stylesheet links are disabled on every open
- Vite dev server proxies {prefix}/assets to summer serve; dist rebuilt
This commit is contained in:
@@ -40,6 +40,8 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
||||
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
||||
export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||
export type PartialNode = Schemas['cabana.PartialNode']
|
||||
/** A rendered header or form partial. */
|
||||
|
||||
@@ -102,8 +102,8 @@ export async function loadControllerAssets(
|
||||
controllerId: string,
|
||||
assets: ControllerAssets | null | undefined,
|
||||
): Promise<string[]> {
|
||||
activateStyles(controllerId)
|
||||
loadStyles(controllerId, assets?.styles ?? [])
|
||||
activateStyles(controllerId)
|
||||
const urls = assets?.scripts ?? []
|
||||
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
|
||||
return urls.filter((_, index) => results[index]?.status === 'rejected')
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Check, Search, Trash2 } from '@lucide/vue'
|
||||
import type { ToolbarAction } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import Button from '../ui/Button.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
|
||||
// selection and a danger outline button next to the "selected" pill with one.
|
||||
defineProps<{
|
||||
showSearch: boolean
|
||||
search: string
|
||||
searchPrompt: string
|
||||
buttons: string[]
|
||||
selectedCount: number
|
||||
selectedLabel: string
|
||||
deleteLabel: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:search': [value: string]; delete: [] }>()
|
||||
// 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.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showSearch: boolean
|
||||
search: string
|
||||
searchPrompt: string
|
||||
buttons: string[]
|
||||
selectedCount: number
|
||||
selectedLabel: string
|
||||
deleteLabel: string
|
||||
actions?: ToolbarAction[]
|
||||
busyAction?: string | null
|
||||
}>(),
|
||||
{ actions: () => [], busyAction: null },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string] }>()
|
||||
|
||||
const labels = computed(() => new Map(props.actions.map((action) => [action.name, action.label])))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,6 +64,17 @@ const emit = defineEmits<{ 'update:search': [value: string]; delete: [] }>()
|
||||
>
|
||||
{{ deleteLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="labels.has(button)"
|
||||
variant="outline"
|
||||
size="md"
|
||||
:data-action="button"
|
||||
:disabled="busyAction === button"
|
||||
:aria-busy="busyAction === button ? 'true' : undefined"
|
||||
@click="emit('action', button)"
|
||||
>
|
||||
{{ labels.get(button) }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { AdminRecord, ListMeta, ListQuery, ListSchema } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { message, t } from '../app/i18n'
|
||||
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
||||
import { loadControllerAssets } from '../app/pluginAssets'
|
||||
import { mapWinterUrl } from '../app/winterUrl'
|
||||
import DataTable from '../components/list/DataTable.vue'
|
||||
import FilterBar from '../components/list/FilterBar.vue'
|
||||
@@ -70,7 +71,13 @@ const subtitle = computed(() => (meta.value ? message(messages.value?.recordCoun
|
||||
|
||||
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
|
||||
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
|
||||
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
|
||||
// Delete and the registered actions the admin may run, in declared order
|
||||
// (D-12). An action missing from toolbarActions is not rendered at all.
|
||||
const toolbarActions = computed(() => schema.value?.toolbarActions ?? [])
|
||||
const toolbarButtons = computed(() =>
|
||||
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
|
||||
)
|
||||
const busyAction = ref<string | null>(null)
|
||||
|
||||
const perPageOptions = computed(() => {
|
||||
const options = schema.value?.perPageOptions ?? []
|
||||
@@ -116,7 +123,10 @@ async function load(): Promise<void> {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
||||
schema.value = result.data?.data ?? null
|
||||
schemaFailed.value = !schema.value
|
||||
|
||||
if (schema.value) {
|
||||
// Plugin files load in the background; the table never waits (D-14).
|
||||
void loadControllerAssets(controllerId, schema.value.assets)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
@@ -216,6 +226,33 @@ async function onDelete(): Promise<void> {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a registered toolbar action (D-12): no confirmation, body {}. */
|
||||
async function onAction(name: string): Promise<void> {
|
||||
if (busyAction.value !== null) {
|
||||
return
|
||||
}
|
||||
busyAction.value = name
|
||||
try {
|
||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/toolbar/{action}', {
|
||||
params: { path: { ...path, action: name } },
|
||||
body: {},
|
||||
})
|
||||
if (result.data) {
|
||||
if (result.data.data.message) {
|
||||
showToast(result.data.data.message)
|
||||
}
|
||||
await loadList()
|
||||
partialReload.value += 1
|
||||
return
|
||||
}
|
||||
showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.extension.action_failed'), 'danger')
|
||||
} finally {
|
||||
busyAction.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -255,8 +292,11 @@ async function onDelete(): Promise<void> {
|
||||
:selected-count="selected.length"
|
||||
:selected-label="message(messages?.selected, selected.length)"
|
||||
:delete-label="message(messages?.deleteSelected, selected.length)"
|
||||
:actions="toolbarActions"
|
||||
:busy-action="busyAction"
|
||||
@update:search="onSearch"
|
||||
@delete="onDelete"
|
||||
@action="onAction"
|
||||
/>
|
||||
<FilterBar
|
||||
v-if="schema && schema.filters.length > 0"
|
||||
|
||||
Reference in New Issue
Block a user