Files
summercms/admin/src/views/ListView.vue
Jakub Zych cd25af0878 fix(admin): scope remembered list state to the signed-in admin and recover from bad restored queries
- Store list queries under summer-admin.list:<admin id>:<controller>; me()
  and setCurrentUser set the owner, which removes other admins' keys and
  the unscoped keys of older builds; nothing is read or written without one
- clearUser removes every stored query, so logout, session expiry and an
  unrecoverable 401 (expireSession, now used by main.ts) all clear them
- A list request built from a restored query that fails for any reason
  (422, 500, network) forgets the query and loads the plain list once
- A failing URL query keeps the error and offers a reset button
  (backend::lang.list.reset_list, en and pl)
- Document remembered list state in docs/backend/lists-and-filters.md
- Rebuild modules/boardwalk/dist
2026-10-09 23:53:19 +02:00

519 lines
18 KiB
Vue

<script setup lang="ts">
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, 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.
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'))
// 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
}
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 })
}
}
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" class="flex items-center gap-2.5">
<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>