Files
summercms/admin/src/views/ListView.vue
Jakub Zych 9d7c21d800 fix(17): WR-07 open a singleton's record from its list route
A settings link to a singleton controller only worked on a plain left
click; a new tab landed on the list route, which answers 404, and every
non-singleton link paid an extra /singleton request. The settings index
now links like any controller link, and the list view opens the
controller's one record when its list answers 404 and the singleton
route answers an id.
2026-10-12 21:43:07 +02:00

561 lines
20 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, Plus, SearchX } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, BulkActionResult, ListMeta, ListQuery, ListSchema } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
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'
import DataTable from '../components/list/DataTable.vue'
import FilterBar from '../components/list/FilterBar.vue'
import ListToolbar from '../components/list/ListToolbar.vue'
import Pagination from '../components/list/Pagination.vue'
import PartialHost from '../components/partial/PartialHost.vue'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { readListState, writeListState } from '../state/listState'
import { activeEntry } from '../state/useNavigation'
import { showToast } from '../state/useToasts'
// Schema-driven list (design screen 3, D-12, D-13, D-14, D-22). Search, sort,
// page, per_page and filters live in the URL query and every change is a
// router replace; any change clears the selection. The view is keyed by
// route path, so it serves one controller. Every query the list loads is also
// kept per list in browser storage (listState.ts, D-01): entered without list
// params, the list restores the stored query into the URL before its first
// list request; a URL with list params wins and overwrites the stored query.
// A restored query the admin did not ask for never breaks the list: if its
// load fails for any reason, the stored query is forgotten and the plain list
// loads once instead (WR-02). A failed URL query keeps the error, with a reset
// back to the plain list next to it. A singleton controller (D-11) has no
// list: its list route answers 404, and the view opens the controller's one
// record instead (a settings link opened in a new tab, a bookmark).
const SEARCH_DEBOUNCE = 300
const route = useRoute()
const router = useRouter()
const path = {
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}
const controllerId = controllerIdFromParams(path)
const basePath = controllerPath(controllerId) ?? '/'
const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
// The schema and the rows load in parallel; either failing is announced, so a
// list response that arrives after a failed schema never hides the failure.
const schemaFailed = ref(false)
const listFailed = ref(false)
const failed = computed(() => schemaFailed.value || listFailed.value)
const selected = ref<RowId[]>([])
const deleting = ref(false)
// Bumped after an action that changes the collection (bulk delete), so the
// header partial refetches. Search, filters, sorting and paging do not: the
// header describes the whole scoped collection, not the filtered page (D-11).
const partialReload = ref(0)
const confirm = useConfirm()
const query = computed(() => parseListQuery(route.query))
const searchText = ref(query.value.search ?? '')
const messages = computed(() => schema.value?.messages)
const title = computed(() => {
if (schema.value?.title) {
return schema.value.title
}
const entry = activeEntry(path.vendor, path.plugin)
return entry?.sideMenu.find((item) => item.controller === controllerId)?.label ?? controllerId
})
const subtitle = computed(() => (meta.value ? message(messages.value?.recordCount, meta.value.total) : ''))
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
// The tree reorder page (D-09): offered when the list declares it; a list
// filtered on the reorder partition column opens that partition.
const reorderLink = computed(() => {
const reorder = schema.value?.reorder
if (!reorder) {
return null
}
const column = reorder.partition?.column
const filter = column ? schema.value?.filters.find((item) => item.column === column || item.name === column) : undefined
const value = filter ? query.value.filter?.[filter.name] : undefined
return { path: `${basePath}/reorder`, query: value !== undefined && value !== '' ? { partition: String(value) } : {} }
})
// 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)
// Row states of the current page and their badge labels (D-12).
const rowStates = computed(() => meta.value?.row_states ?? {})
const stateLabels = computed(() => ({
deleted: message(messages.value?.rowStateDeleted),
negative: message(messages.value?.rowStateNegative),
disabled: message(messages.value?.rowStateDisabled),
}))
// 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 ?? []
return options.length > 0 ? options : [schema.value?.recordsPerPage || 20]
})
const perPage = computed(() => query.value.per_page ?? meta.value?.per_page ?? schema.value?.recordsPerPage ?? 20)
const sort = computed(() =>
query.value.sort ? { column: query.value.sort, dir: query.value.dir === 'desc' ? ('desc' as const) : ('asc' as const) } : null,
)
/** Record route of a row when the list declares a recordUrl (D-10). */
function rowLink(row: AdminRecord): string | null {
const recordUrl = schema.value?.recordUrl
const id = row.id
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
return null
}
return mapWinterUrl(recordUrl, controllerId, id)
}
function replaceQuery(next: ListQuery): void {
// Stored first, so clearing every control stores the empty query and the
// following run loads the unfiltered list instead of restoring.
writeListState(controllerId, next)
void router.replace({ path: route.path, query: toListQuery(next) })
}
const EMPTY_QUERY_KEY = listQueryKey({})
const hasQuery = computed(() => listQueryKey(query.value) !== EMPTY_QUERY_KEY)
let generation = 0
// Key of the query the watch restored from storage and has not loaded yet.
let restoredKey: string | null = null
/** Drops every list param: the stored query is forgotten, the plain list loads. */
function resetQuery(): void {
replaceQuery({})
}
/**
* Loads the rows of the URL query. restored marks the first load of a query
* restored from storage: if it fails for any reason (422, 500, network), the
* stored query is forgotten and the plain list loads. The plain list is never
* restored again, so this happens at most once per entry.
*/
async function loadList(restored = false): Promise<void> {
const current = ++generation
loading.value = true
listFailed.value = false
let result: Awaited<ReturnType<typeof fetchList>> | null = null
try {
result = await fetchList()
} catch {
result = null
}
if (current !== generation) {
return
}
if (!result?.data && restored) {
// Keeps loading until the plain list answers; no error flashes.
writeListState(controllerId, {})
void router.replace({ path: route.path, query: {} })
return
}
if (!result?.data && result?.response?.status === 404 && (await openSingleton(current))) {
// Keeps loading while the record opens; no error flashes.
return
}
rows.value = result?.data?.data ?? []
meta.value = result?.data?.meta ?? null
listFailed.value = !result?.data
loading.value = false
if (result?.response?.status === 422) {
// A stale stored filter or sort never sticks.
writeListState(controllerId, {})
return
}
const lastPage = meta.value?.last_page ?? 0
if (lastPage >= 1 && (meta.value?.page ?? 0) > lastPage) {
// Records were removed since the query was stored or shared.
replaceQuery({ ...query.value, page: lastPage })
}
}
/**
* Opens the one record of a singleton controller (D-11) in place of its list,
* which answers 404. Reports whether it took over: false when the controller
* is not a singleton (its 404 stands).
*/
async function openSingleton(current: number): Promise<boolean> {
try {
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/singleton', { params: { path } })
if (current !== generation) {
return true
}
if (!data) {
return false
}
await router.replace(`${basePath}/${data.data.id}`)
return true
} catch {
return false
}
}
function fetchList() {
return api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
}
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()
watch(
() => listQueryKey(query.value),
(key) => {
// Entered without list params (navigation, breadcrumb, a form's back,
// cancel, save or delete, or the same list's link while it is filtered):
// restore the stored query into the URL and let this watch load it.
if (key === EMPTY_QUERY_KEY) {
const stored = readListState(controllerId)
const storedKey = listQueryKey(stored)
if (storedKey !== EMPTY_QUERY_KEY) {
restoredKey = storedKey
void router.replace({ path: route.path, query: toListQuery(stored) })
return
}
}
const restored = restoredKey !== null && restoredKey === key
restoredKey = null
writeListState(controllerId, query.value)
selected.value = []
searchText.value = query.value.search ?? ''
void loadList(restored)
},
{ immediate: true },
)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearch(value: string): void {
searchText.value = value
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
const search = value.trim()
replaceQuery({ ...query.value, search: search === '' ? undefined : search, page: undefined })
}, SEARCH_DEBOUNCE)
}
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
function clearSearch(): void {
searchText.value = ''
replaceQuery({ ...query.value, search: undefined, page: undefined })
}
/** asc -> desc -> none for the clicked column; another column starts at asc. */
function onSort(column: string): void {
const current = sort.value
if (current?.column !== column) {
replaceQuery({ ...query.value, sort: column, dir: 'asc', page: undefined })
} else if (current.dir === 'asc') {
replaceQuery({ ...query.value, sort: column, dir: 'desc', page: undefined })
} else {
replaceQuery({ ...query.value, sort: undefined, dir: undefined, page: undefined })
}
}
function onPage(page: number): void {
replaceQuery({ ...query.value, page })
}
function onPerPage(value: number): void {
replaceQuery({ ...query.value, per_page: value, page: undefined })
}
function onFilter(name: string, value: string | null): void {
const filter = { ...(query.value.filter ?? {}) }
if (value === null) {
delete filter[name]
} else {
filter[name] = value
}
replaceQuery({ ...query.value, filter, page: undefined })
}
async function onDelete(): Promise<void> {
const ids = selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
if (ids.length === 0 || deleting.value) {
return
}
const ok = await confirm.ask({
message: message(messages.value?.deleteConfirm, ids.length),
confirmLabel: t('backend::lang.form.delete'),
danger: true,
})
if (!ok) {
return
}
deleting.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk-delete', { params: { path }, body: { ids } })
if (result.data) {
showToast(message(messages.value?.deleted, result.data.data.deleted))
selected.value = []
await loadList()
partialReload.value += 1
return
}
showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger')
} catch {
showToast(t('backend::lang.list.delete_failed'), 'danger')
} finally {
deleting.value = false
}
}
/**
* 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) {
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>
<section class="flex flex-col gap-5">
<header class="flex flex-wrap items-end justify-between gap-4">
<div class="flex flex-col">
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
<p v-if="subtitle" data-record-count class="text-muted">{{ subtitle }}</p>
</div>
<div v-if="headingButtons.length > 0 || reorderLink" class="flex items-center gap-2.5">
<Button v-if="reorderLink" data-action="reorder" variant="outline" :icon="ArrowUpDown" :to="reorderLink">
{{ t('backend::lang.messages.list.reorder') }}
</Button>
<Button
v-for="button in headingButtons"
:key="button"
data-action="create"
variant="primary"
:icon="Plus"
:to="`${basePath}/create`"
>
{{ message(messages?.create) }}
</Button>
</div>
</header>
<PartialHost
v-if="schema?.headerPartial"
variant="header"
:source="path"
:name="schema.headerPartial"
:reload-key="partialReload"
/>
<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)"
:buttons="toolbarButtons"
:selected-count="selected.length"
:selected-label="message(messages?.selected, selected.length)"
: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"
:filters="schema.filters"
:values="query.filter ?? {}"
:source="path"
@update="onFilter"
/>
<div
v-if="failed && !loading"
data-list-failed
class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4"
>
<p role="alert" class="text-danger">{{ t('backend::lang.list.load_failed') }}</p>
<Button v-if="listFailed && hasQuery" data-action="reset-list" @click="resetQuery">
{{ t('backend::lang.list.reset_list') }}
</Button>
</div>
<DataTable
v-else
:columns="schema?.columns ?? []"
:rows="rows"
:loading="loading || !schema"
:row-link="rowLink"
:selectable="schema?.showCheckboxes ?? false"
:selected="selected"
:sortable="schema?.showSorting ?? false"
:sort="sort"
:row-states="rowStates"
:state-labels="stateLabels"
@update:selected="(ids: RowId[]) => (selected = ids)"
@sort="onSort"
>
<template #empty>
<div v-if="query.search" data-empty-search class="flex flex-col items-center gap-3">
<span class="flex size-14 items-center justify-center rounded-full bg-subtle text-muted">
<SearchX :size="24" aria-hidden="true" />
</span>
<p class="text-[17px] font-bold">{{ message(messages?.emptySearch, undefined, { term: query.search }) }}</p>
<p class="max-w-[420px] text-muted">
{{ message(messages?.emptySearchHint, undefined, { term: query.search }) }}
</p>
<Button data-action="clear-search" @click="clearSearch">{{ t('backend::lang.list.clear_search') }}</Button>
</div>
<p v-else data-empty-list class="text-muted">
{{ message(messages?.empty) || schema?.noRecordsMessage || t('backend::lang.list.no_records') }}
</p>
</template>
</DataTable>
<Pagination
:meta="meta"
:loading="loading || !schema"
:per-page-options="perPageOptions"
:per-page="perPage"
@page="onPage"
@per-page="onPerPage"
/>
</div>
<ConfirmDialog
:open="confirm.request.value !== null"
: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"
/>
</section>
</template>