- listState.ts stores each list's query (search, sort, dir, page, per_page, filter[name]) under summer-admin.list:<controller>; an empty query is never stored, malformed stored values are dropped like a hand-edited URL - ListView restores the stored query into the URL before its first list request when entered without list params; a URL with list params wins - a 422 forgets the stored query; an out-of-range page clamps to last_page - logout clears every stored list query - check-phase10.sh allows browser storage in useSidebar.ts and listState.ts - list smoke tests start from a clean list state - rebuilt modules/boardwalk/dist
474 lines
17 KiB
Vue
474 lines
17 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.
|
|
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({})
|
|
|
|
let generation = 0
|
|
|
|
async function loadList(): Promise<void> {
|
|
const current = ++generation
|
|
loading.value = true
|
|
listFailed.value = false
|
|
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
|
if (current !== generation) {
|
|
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 })
|
|
}
|
|
}
|
|
|
|
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)
|
|
if (listQueryKey(stored) !== EMPTY_QUERY_KEY) {
|
|
void router.replace({ path: route.path, query: toListQuery(stored) })
|
|
return
|
|
}
|
|
}
|
|
writeListState(controllerId, query.value)
|
|
selected.value = []
|
|
searchText.value = query.value.search ?? ''
|
|
void loadList()
|
|
},
|
|
{ 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"
|
|
/>
|
|
<p v-if="failed && !loading" role="alert" class="border-t border-border px-5 py-4 text-danger">
|
|
{{ t('backend::lang.list.load_failed') }}
|
|
</p>
|
|
<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>
|