feat(10-03): search, sort, filter, page and bulk-delete any list
- List state (search, sort, dir, page, per_page, filter[<name>]) lives in
the URL query through parseListQuery/toListQuery; every change is a
router replace and clears the selection; search is debounced 300 ms and
resets the page
- DataTable renders the schema columns with a tri-state page checkbox,
asc/desc/none sorting with aria-sort, selected rows, a sticky header,
eight skeleton rows while loading and an empty slot
- CellValue renders text (muted dash when empty, arrays comma-joined),
datetime as YYYY-MM-DD HH:mm and switch as the Tak/Nie pills
- The heading shows the plural recordCount and the create button;
delete sits in the toolbar, disabled without a selection, and confirms
with the plural deleteConfirm in a Reka alert dialog before POSTing
bulk-delete; a 409 shows a danger toast
- FilterBar renders switch (JSON of the option value), daterange
(from..to) and scope filters (choices from filters/{scope}/options)
- Pagination shows the range, the per-page select over perPageOptions
(hidden with one choice) and a pager with ellipsis
- The tracer smoke test skips the new checkbox column; new backend::lang
list keys; boardwalk/dist rebuilt
This commit is contained in:
@@ -1,46 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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, ListSchema } from '../api/types'
|
||||
import { controllerIdFromParams } from '../app/controllerRoutes'
|
||||
import { t } from '../app/i18n'
|
||||
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 { 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 Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
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.
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const path = computed(() => ({
|
||||
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)
|
||||
const failed = ref(false)
|
||||
const selected = ref<RowId[]>([])
|
||||
const deleting = ref(false)
|
||||
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 id = controllerIdFromParams(path.value)
|
||||
const entry = activeEntry(path.value.vendor, path.value.plugin)
|
||||
return entry?.sideMenu.find((item) => item.controller === id)?.label ?? id
|
||||
const entry = activeEntry(path.vendor, path.plugin)
|
||||
return entry?.sideMenu.find((item) => item.controller === controllerId)?.label ?? controllerId
|
||||
})
|
||||
|
||||
const range = computed(() => {
|
||||
const m = meta.value
|
||||
if (!m || m.total === 0) {
|
||||
return t('backend::lang.list.no_results')
|
||||
}
|
||||
const from = (m.page - 1) * m.per_page + 1
|
||||
const to = Math.min(m.page * m.per_page, m.total)
|
||||
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
|
||||
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'))
|
||||
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
|
||||
|
||||
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 {
|
||||
@@ -49,53 +80,224 @@ function rowLink(row: AdminRecord): string | null {
|
||||
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
|
||||
return null
|
||||
}
|
||||
return mapWinterUrl(recordUrl, controllerIdFromParams(path.value), id)
|
||||
return mapWinterUrl(recordUrl, controllerId, id)
|
||||
}
|
||||
|
||||
function replaceQuery(next: ListQuery): void {
|
||||
void router.replace({ path: route.path, query: toListQuery(next) })
|
||||
}
|
||||
|
||||
let generation = 0
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function loadList(): Promise<void> {
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
const params = { path: path.value }
|
||||
const [schemaResult, listResult] = await Promise.all([
|
||||
api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params }),
|
||||
api.GET('/{vendor}/{plugin}/{controller}', { params }),
|
||||
])
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
schema.value = schemaResult.data?.data ?? null
|
||||
rows.value = listResult.data?.data ?? []
|
||||
meta.value = listResult.data?.meta ?? null
|
||||
failed.value = !schemaResult.data || !listResult.data
|
||||
rows.value = result.data?.data ?? []
|
||||
meta.value = result.data?.meta ?? null
|
||||
failed.value = !result.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(path, load, { immediate: true, deep: true })
|
||||
async function load(): Promise<void> {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
||||
schema.value = result.data?.data ?? null
|
||||
if (!schema.value) {
|
||||
failed.value = true
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
watch(
|
||||
() => listQueryKey(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()
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-5">
|
||||
<header>
|
||||
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
|
||||
<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>
|
||||
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
|
||||
<p v-if="failed && !loading" role="alert" class="px-5 py-4 text-danger">
|
||||
<ListToolbar
|
||||
v-if="schema"
|
||||
: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)"
|
||||
@update:search="onSearch"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
<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"
|
||||
:empty-text="schema?.noRecordsMessage"
|
||||
:loading="loading || !schema"
|
||||
:row-link="rowLink"
|
||||
:selectable="schema?.showCheckboxes ?? false"
|
||||
:selected="selected"
|
||||
:sortable="schema?.showSorting ?? false"
|
||||
:sort="sort"
|
||||
@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"
|
||||
/>
|
||||
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
|
||||
{{ loading ? t('backend::lang.list.loading') : range }}
|
||||
</footer>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user