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:
85
admin/src/app/listQuery.ts
Normal file
85
admin/src/app/listQuery.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// List state lives in the URL query (design: Interactions, D-22): search,
|
||||
// sort, dir, page, per_page and filter[<name>]. These helpers convert between
|
||||
// the route query and the typed list query the API client sends. Anything
|
||||
// malformed (a non-numeric page, an unknown direction, an empty filter) is
|
||||
// dropped, so a hand-edited URL never reaches the server half-valid.
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||
import type { ListQuery } from '../api/types'
|
||||
|
||||
/** A list row's id as the record carries it. */
|
||||
export type RowId = number | string
|
||||
|
||||
const FILTER_KEY = /^filter\[(.+)\]$/
|
||||
const POSITIVE = /^[1-9]\d*$/
|
||||
|
||||
function first(value: LocationQuery[string] | undefined): string {
|
||||
const raw = Array.isArray(value) ? value[value.length - 1] : value
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}
|
||||
|
||||
function positive(value: string): number | undefined {
|
||||
return POSITIVE.test(value) ? Number(value) : undefined
|
||||
}
|
||||
|
||||
export function parseListQuery(query: LocationQuery): ListQuery {
|
||||
const out: ListQuery = {}
|
||||
const search = first(query.search).trim()
|
||||
if (search !== '') {
|
||||
out.search = search
|
||||
}
|
||||
const sort = first(query.sort).trim()
|
||||
if (sort !== '') {
|
||||
out.sort = sort
|
||||
const dir = first(query.dir)
|
||||
out.dir = dir === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
const page = positive(first(query.page))
|
||||
if (page !== undefined) {
|
||||
out.page = page
|
||||
}
|
||||
const perPage = positive(first(query.per_page))
|
||||
if (perPage !== undefined) {
|
||||
out.per_page = perPage
|
||||
}
|
||||
const filter: Record<string, string> = {}
|
||||
for (const key of Object.keys(query).sort()) {
|
||||
const name = FILTER_KEY.exec(key)?.[1]
|
||||
const value = first(query[key])
|
||||
if (name && value !== '') {
|
||||
filter[name] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(filter).length > 0) {
|
||||
out.filter = filter
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function toListQuery(input: ListQuery): LocationQueryRaw {
|
||||
const out: LocationQueryRaw = {}
|
||||
if (input.search) {
|
||||
out.search = input.search
|
||||
}
|
||||
if (input.sort) {
|
||||
out.sort = input.sort
|
||||
out.dir = input.dir === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
if (input.page !== undefined && input.page > 1) {
|
||||
out.page = String(input.page)
|
||||
}
|
||||
if (input.per_page !== undefined) {
|
||||
out.per_page = String(input.per_page)
|
||||
}
|
||||
for (const name of Object.keys(input.filter ?? {}).sort()) {
|
||||
const value = input.filter?.[name]
|
||||
if (value !== undefined && value !== '') {
|
||||
out[`filter[${name}]`] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A stable key for comparing two list queries. */
|
||||
export function listQueryKey(input: ListQuery): string {
|
||||
return JSON.stringify(toListQuery(input))
|
||||
}
|
||||
86
admin/src/components/list/CellValue.vue
Normal file
86
admin/src/components/list/CellValue.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
import type { ListColumn } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// One cell rendered by column type (D-12): text by default (arrays
|
||||
// comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with
|
||||
// tabular numbers, switch as the Tak/Nie pills. No per-value icon pills.
|
||||
const props = defineProps<{ column: ListColumn; value: unknown }>()
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === ''
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
if (isEmpty(value)) {
|
||||
return ''
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(text).filter((part) => part !== '').join(', ')
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function datetime(value: unknown): string {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value)
|
||||
}
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function truthy(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
const kind = computed(() => {
|
||||
if (props.column.type === 'switch') {
|
||||
return 'switch'
|
||||
}
|
||||
if (props.column.type === 'datetime') {
|
||||
return 'datetime'
|
||||
}
|
||||
return 'text'
|
||||
})
|
||||
|
||||
const display = computed(() => (kind.value === 'datetime' ? datetime(props.value) : text(props.value)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="kind === 'switch' && !isEmpty(value)">
|
||||
<span
|
||||
v-if="truthy(value)"
|
||||
data-switch="true"
|
||||
class="inline-flex h-6 items-center gap-1 rounded-pill bg-ok-bg px-2.5 text-[12px] font-semibold text-ok-text"
|
||||
>
|
||||
<ArrowUpRight :size="14" aria-hidden="true" />{{ t('backend::lang.list.column_switch_true') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
data-switch="false"
|
||||
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[12px] font-semibold text-muted"
|
||||
>{{ t('backend::lang.list.column_switch_false') }}</span
|
||||
>
|
||||
</template>
|
||||
<span v-else-if="display === ''" class="text-muted" data-empty>—</span>
|
||||
<span v-else-if="kind === 'datetime'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
|
||||
<template v-else>{{ display }}</template>
|
||||
</template>
|
||||
@@ -1,28 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { ArrowDown, ArrowUp, Check, Minus } from '@lucide/vue'
|
||||
import type { AdminRecord, ListColumn } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import type { RowId } from '../../app/listQuery'
|
||||
import CellValue from './CellValue.vue'
|
||||
|
||||
// Schema-driven table: columns come from the list schema and each record is
|
||||
// read through its column keys (D-16). With rowLink a row opens its record
|
||||
// route; the first cell carries the link for keyboard users.
|
||||
const props = defineProps<{
|
||||
columns: ListColumn[]
|
||||
rows: AdminRecord[]
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
rowLink?: (row: AdminRecord) => string | null
|
||||
}>()
|
||||
// Schema-driven table (design screen 3, D-12, D-16): exactly the server's
|
||||
// columns, each record read through its column keys. Optional checkbox
|
||||
// column with a tri-state header over the current page, sortable headers
|
||||
// (asc -> desc -> none) with aria-sort, row links to the record route, eight
|
||||
// skeleton rows while loading and an `empty` slot for the empty states.
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: ListColumn[]
|
||||
rows: AdminRecord[]
|
||||
loading?: boolean
|
||||
rowLink?: (row: AdminRecord) => string | null
|
||||
selectable?: boolean
|
||||
selected?: RowId[]
|
||||
sortable?: boolean
|
||||
sort?: { column: string; dir: 'asc' | 'desc' } | null
|
||||
}>(),
|
||||
{ loading: false, rowLink: undefined, selectable: false, selected: () => [], sortable: false, sort: null },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function rowId(row: AdminRecord): RowId | null {
|
||||
const id = row.id
|
||||
return typeof id === 'number' || typeof id === 'string' ? id : null
|
||||
}
|
||||
|
||||
function rowKey(row: AdminRecord, index: number): string {
|
||||
const id = rowId(row)
|
||||
return id === null ? `row-${index}` : String(id)
|
||||
}
|
||||
|
||||
function linkOf(row: AdminRecord): string | null {
|
||||
return props.rowLink ? props.rowLink(row) : null
|
||||
}
|
||||
|
||||
function openRow(event: MouseEvent, row: AdminRecord): void {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('a, button, input, label')) {
|
||||
if (target?.closest('a, button, input, label, select')) {
|
||||
return
|
||||
}
|
||||
const to = linkOf(row)
|
||||
@@ -31,79 +55,152 @@ function openRow(event: MouseEvent, row: AdminRecord): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cellText(row: AdminRecord, column: ListColumn): string {
|
||||
return formatValue(row[column.key])
|
||||
const pageIds = computed(() => props.rows.map(rowId).filter((id): id is RowId => id !== null))
|
||||
const selectedSet = computed(() => new Set(props.selected.map(String)))
|
||||
|
||||
const headerState = computed<'true' | 'false' | 'mixed'>(() => {
|
||||
const ids = pageIds.value
|
||||
const chosen = ids.filter((id) => selectedSet.value.has(String(id))).length
|
||||
if (chosen === 0) {
|
||||
return 'false'
|
||||
}
|
||||
return chosen === ids.length ? 'true' : 'mixed'
|
||||
})
|
||||
|
||||
function isSelected(row: AdminRecord): boolean {
|
||||
const id = rowId(row)
|
||||
return id !== null && selectedSet.value.has(String(id))
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value.map(formatValue).filter((part) => part !== '—')
|
||||
return parts.length === 0 ? '—' : parts.join(', ')
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return '—'
|
||||
}
|
||||
return String(value)
|
||||
function toggleAll(): void {
|
||||
emit('update:selected', headerState.value === 'true' ? [] : [...pageIds.value])
|
||||
}
|
||||
|
||||
function rowKey(row: AdminRecord, index: number): string {
|
||||
const id = row.id
|
||||
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
|
||||
function toggleRow(row: AdminRecord): void {
|
||||
const id = rowId(row)
|
||||
if (id === null) {
|
||||
return
|
||||
}
|
||||
if (selectedSet.value.has(String(id))) {
|
||||
emit(
|
||||
'update:selected',
|
||||
props.selected.filter((item) => String(item) !== String(id)),
|
||||
)
|
||||
} else {
|
||||
emit('update:selected', [...props.selected, id])
|
||||
}
|
||||
}
|
||||
|
||||
function sortState(column: ListColumn): 'ascending' | 'descending' | undefined {
|
||||
if (props.sort?.column !== column.key) {
|
||||
return undefined
|
||||
}
|
||||
return props.sort.dir === 'desc' ? 'descending' : 'ascending'
|
||||
}
|
||||
|
||||
function canSort(column: ListColumn): boolean {
|
||||
return props.sortable && column.sortable
|
||||
}
|
||||
|
||||
const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1))
|
||||
|
||||
// Varied skeleton bar widths, as in the design.
|
||||
const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', 'w-2/5']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[640px] border-collapse text-left">
|
||||
<thead>
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="h-row-head border-y border-border bg-subtle">
|
||||
<th v-if="selectable" scope="col" data-select class="w-[52px] pl-5">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
:aria-checked="headerState"
|
||||
:aria-label="t('backend::lang.list.select_all')"
|
||||
:disabled="loading || pageIds.length === 0"
|
||||
:class="headerState === 'false' ? 'border-border-strong bg-surface' : 'border-primary bg-primary text-on-primary'"
|
||||
class="flex size-[18px] items-center justify-center rounded-checkbox border disabled:opacity-60"
|
||||
@click="toggleAll"
|
||||
>
|
||||
<Minus v-if="headerState === 'mixed'" :size="14" aria-hidden="true" />
|
||||
<Check v-else-if="headerState === 'true'" :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
class="px-3.5 text-[12px] font-semibold text-muted first:pl-5 last:pr-5"
|
||||
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
|
||||
:class="sortState(column) ? 'text-text' : 'text-muted'"
|
||||
class="px-3.5 text-[12px] font-semibold first:pl-5 last:pr-5"
|
||||
>
|
||||
{{ column.label }}
|
||||
<button
|
||||
v-if="canSort(column)"
|
||||
type="button"
|
||||
:data-sort="column.key"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
@click="emit('sort', column.key)"
|
||||
>
|
||||
{{ column.label }}
|
||||
<ArrowUp v-if="sortState(column) === 'ascending'" :size="14" aria-hidden="true" />
|
||||
<ArrowDown v-else-if="sortState(column) === 'descending'" :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<template v-else>{{ column.label }}</template>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :aria-busy="loading ? 'true' : undefined">
|
||||
<template v-if="loading">
|
||||
<tr v-for="n in 8" :key="`skeleton-${n}`" class="h-row border-b border-border">
|
||||
<tr v-for="n in 8" :key="`skeleton-${n}`" data-skeleton class="h-row border-b border-border">
|
||||
<td v-if="selectable" class="pl-5">
|
||||
<span class="block size-[18px] rounded-checkbox bg-skel" />
|
||||
</td>
|
||||
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
||||
<span class="block h-3 w-3/5 rounded-[6px] bg-skel" />
|
||||
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="rows.length === 0">
|
||||
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
|
||||
{{ emptyText ?? t('backend::lang.list.no_records') }}
|
||||
<td :colspan="span" class="px-5 py-20 text-center">
|
||||
<slot name="empty">
|
||||
<span class="text-muted">{{ t('backend::lang.list.no_records') }}</span>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="(row, index) in rows"
|
||||
:key="rowKey(row, index)"
|
||||
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
|
||||
class="h-row border-b border-border"
|
||||
:class="[isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '', linkOf(row) ? 'cursor-pointer' : '']"
|
||||
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
|
||||
class="h-row border-b border-border transition-colors duration-150 ease-out"
|
||||
@click="openRow($event, row)"
|
||||
>
|
||||
<td v-if="selectable" data-select class="pl-5">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
:aria-checked="isSelected(row) ? 'true' : 'false'"
|
||||
:aria-label="t('backend::lang.list.select_row')"
|
||||
:class="isSelected(row) ? 'border-primary bg-primary text-on-primary' : 'border-border-strong bg-surface'"
|
||||
class="flex size-[18px] items-center justify-center rounded-checkbox border"
|
||||
@click="toggleRow(row)"
|
||||
>
|
||||
<Check v-if="isSelected(row)" :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="column.key"
|
||||
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
|
||||
:class="columnIndex === 0 ? 'font-semibold' : column.relation ? 'text-muted' : ''"
|
||||
class="px-3.5 first:pl-5 last:pr-5"
|
||||
>
|
||||
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
|
||||
{{ cellText(row, column) }}
|
||||
<CellValue :column="column" :value="row[column.key]" />
|
||||
</RouterLink>
|
||||
<template v-else>{{ cellText(row, column) }}</template>
|
||||
<CellValue v-else :column="column" :value="row[column.key]" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
161
admin/src/components/list/FilterBar.vue
Normal file
161
admin/src/components/list/FilterBar.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { ControllerParams, FilterOption, ListFilter } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Filter bar (D-22, D-27) for the three Phase 9 filter shapes. Each control
|
||||
// writes filter[<name>] through the parent (which resets the page); the wire
|
||||
// values follow cabana's list query:
|
||||
// switch JSON of the chosen option value (JSON.stringify)
|
||||
// daterange YYYY-MM-DD..YYYY-MM-DD
|
||||
// scope the chosen value from GET filters/{scope}/options
|
||||
const props = defineProps<{
|
||||
filters: ListFilter[]
|
||||
values: Record<string, string>
|
||||
source: ControllerParams
|
||||
}>()
|
||||
const emit = defineEmits<{ update: [name: string, value: string | null] }>()
|
||||
|
||||
const ALL = ''
|
||||
|
||||
interface Choice {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function switchChoices(filter: ListFilter): Choice[] {
|
||||
if (filter.options && filter.options.length > 0) {
|
||||
return filter.options.map((option) => ({ value: JSON.stringify(option.value), label: option.label }))
|
||||
}
|
||||
const out: Choice[] = []
|
||||
if (filter.trueValue !== undefined) {
|
||||
out.push({ value: JSON.stringify(filter.trueValue), label: t('backend::lang.list.column_switch_true') })
|
||||
}
|
||||
if (filter.falseValue !== undefined) {
|
||||
out.push({ value: JSON.stringify(filter.falseValue), label: t('backend::lang.list.column_switch_false') })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Scope choices come from the model's FilterOptions, loaded once per filter.
|
||||
const scopeChoices = ref<Record<string, FilterOption[]>>({})
|
||||
|
||||
async function loadScope(filter: ListFilter): Promise<void> {
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/filters/{scope}/options', {
|
||||
params: { path: { ...props.source, scope: filter.name } },
|
||||
})
|
||||
scopeChoices.value = { ...scopeChoices.value, [filter.name]: data?.data ?? [] }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
for (const filter of props.filters) {
|
||||
if (filter.type === 'scope') {
|
||||
void loadScope(filter)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Date ranges keep a half-filled pair locally; the filter is written only
|
||||
// when both ends are set, and removed when both are cleared.
|
||||
const ranges = reactive<Record<string, { from: string; to: string }>>({})
|
||||
|
||||
function syncRanges(): void {
|
||||
for (const filter of props.filters) {
|
||||
if (filter.type !== 'daterange') {
|
||||
continue
|
||||
}
|
||||
const [from = '', to = ''] = (props.values[filter.name] ?? '').split('..')
|
||||
const current = ranges[filter.name]
|
||||
if (!current || props.values[filter.name] !== undefined || (current.from !== '' && current.to !== '')) {
|
||||
ranges[filter.name] = { from, to }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.values, syncRanges, { immediate: true, deep: true })
|
||||
|
||||
function onRange(filter: ListFilter, end: 'from' | 'to', value: string): void {
|
||||
const range = { ...(ranges[filter.name] ?? { from: '', to: '' }), [end]: value }
|
||||
ranges[filter.name] = range
|
||||
if (range.from !== '' && range.to !== '') {
|
||||
emit('update', filter.name, `${range.from}..${range.to}`)
|
||||
} else if (range.from === '' && range.to === '') {
|
||||
emit('update', filter.name, null)
|
||||
} else if (props.values[filter.name] !== undefined) {
|
||||
emit('update', filter.name, null)
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(filter: ListFilter, value: string): void {
|
||||
emit('update', filter.name, value === ALL ? null : value)
|
||||
}
|
||||
|
||||
const selectClass = 'h-button rounded-control border border-border-strong bg-surface px-3 text-text'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
:aria-label="t('backend::lang.list.filters')"
|
||||
data-filter-bar
|
||||
class="flex flex-wrap items-end gap-4 border-t border-border px-5 py-3.5"
|
||||
>
|
||||
<template v-for="filter in filters" :key="filter.name">
|
||||
<label v-if="filter.type === 'switch'" class="flex flex-col gap-1 text-[13px]">
|
||||
<span class="font-semibold text-muted">{{ filter.label || filter.name }}</span>
|
||||
<select
|
||||
:data-filter="filter.name"
|
||||
:value="values[filter.name] ?? ALL"
|
||||
:class="selectClass"
|
||||
@change="onSelect(filter, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option :value="ALL">{{ t('backend::lang.list.filter_all') }}</option>
|
||||
<option v-for="choice in switchChoices(filter)" :key="choice.value" :value="choice.value">
|
||||
{{ choice.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<fieldset v-else-if="filter.type === 'daterange'" class="flex flex-col gap-1 text-[13px]">
|
||||
<legend class="mb-1 font-semibold text-muted">{{ filter.label || filter.name }}</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
:data-filter="`${filter.name}-from`"
|
||||
:aria-label="t('backend::lang.list.filter_from')"
|
||||
:value="ranges[filter.name]?.from ?? ''"
|
||||
:class="selectClass"
|
||||
@change="onRange(filter, 'from', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span aria-hidden="true">–</span>
|
||||
<input
|
||||
type="date"
|
||||
:data-filter="`${filter.name}-to`"
|
||||
:aria-label="t('backend::lang.list.filter_to')"
|
||||
:value="ranges[filter.name]?.to ?? ''"
|
||||
:class="selectClass"
|
||||
@change="onRange(filter, 'to', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label v-else-if="filter.type === 'scope'" class="flex flex-col gap-1 text-[13px]">
|
||||
<span class="font-semibold text-muted">{{ filter.label || filter.name }}</span>
|
||||
<!-- Re-created when the choices arrive so the URL value selects. -->
|
||||
<select
|
||||
:key="`${filter.name}-${scopeChoices[filter.name]?.length ?? -1}`"
|
||||
:data-filter="filter.name"
|
||||
:value="values[filter.name] ?? ALL"
|
||||
:class="selectClass"
|
||||
@change="onSelect(filter, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option :value="ALL">{{ t('backend::lang.list.filter_all') }}</option>
|
||||
<option v-for="choice in scopeChoices[filter.name] ?? []" :key="choice.value" :value="choice.value">
|
||||
{{ choice.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
58
admin/src/components/list/ListToolbar.vue
Normal file
58
admin/src/components/list/ListToolbar.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Search, Trash2 } from '@lucide/vue'
|
||||
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: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||
<label v-if="showSearch" class="relative block w-[320px] max-w-full">
|
||||
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
|
||||
<Search :size="18" class="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
data-list-search
|
||||
:value="search"
|
||||
:placeholder="searchPrompt"
|
||||
class="h-button w-full rounded-control border border-transparent bg-subtle pr-3.5 pl-10"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span v-else />
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
data-selected-pill
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-pill bg-sel px-3 text-[13px] font-semibold"
|
||||
>
|
||||
<Check :size="14" aria-hidden="true" />{{ selectedLabel }}
|
||||
</span>
|
||||
<template v-for="button in buttons" :key="button">
|
||||
<Button
|
||||
v-if="button === 'delete'"
|
||||
data-action="delete"
|
||||
:variant="selectedCount > 0 ? 'danger' : 'outline'"
|
||||
:icon="Trash2"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ deleteLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
113
admin/src/components/list/Pagination.vue
Normal file
113
admin/src/components/list/Pagination.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import type { ListMeta } from '../../api/types'
|
||||
import { t, tc } from '../../app/i18n'
|
||||
|
||||
// List footer (design screen 3): the range ":from–:to z :total", the
|
||||
// per-page select over the schema's options (hidden with one choice,
|
||||
// research Gap 7) and a pager with ellipsis. Zero rows show the plural
|
||||
// results message and no pager.
|
||||
const props = defineProps<{
|
||||
meta: ListMeta | null
|
||||
loading: boolean
|
||||
perPageOptions: number[]
|
||||
perPage: number
|
||||
}>()
|
||||
const emit = defineEmits<{ page: [page: number]; perPage: [perPage: number] }>()
|
||||
|
||||
const total = computed(() => props.meta?.total ?? 0)
|
||||
const page = computed(() => props.meta?.page ?? 1)
|
||||
const lastPage = computed(() => Math.max(props.meta?.last_page ?? 1, 1))
|
||||
|
||||
const range = computed(() => {
|
||||
const m = props.meta
|
||||
if (!m || m.total === 0) {
|
||||
return tc('backend::lang.list.results', 0)
|
||||
}
|
||||
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 })
|
||||
})
|
||||
|
||||
/** Page numbers with null for an ellipsis: 1 … p-1 p p+1 … last. */
|
||||
const pages = computed<Array<number | null>>(() => {
|
||||
const last = lastPage.value
|
||||
const current = page.value
|
||||
const wanted = new Set([1, last, current - 1, current, current + 1].filter((n) => n >= 1 && n <= last))
|
||||
if (current <= 3) {
|
||||
;[2, 3].forEach((n) => n <= last && wanted.add(n))
|
||||
}
|
||||
if (current >= last - 2) {
|
||||
;[last - 1, last - 2].forEach((n) => n >= 1 && wanted.add(n))
|
||||
}
|
||||
const sorted = [...wanted].sort((a, b) => a - b)
|
||||
const out: Array<number | null> = []
|
||||
sorted.forEach((n, index) => {
|
||||
const prev = sorted[index - 1]
|
||||
if (prev !== undefined && n - prev > 1) {
|
||||
out.push(null)
|
||||
}
|
||||
out.push(n)
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
function onPerPage(event: Event): void {
|
||||
const value = Number((event.target as HTMLSelectElement).value)
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
emit('perPage', value)
|
||||
}
|
||||
}
|
||||
|
||||
const pagerButton =
|
||||
'inline-flex h-pager min-w-pager items-center justify-center gap-1 rounded-pager border border-border px-2.5 text-[13px] text-text disabled:cursor-not-allowed disabled:opacity-60 hover:bg-hover'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-border px-5 py-3.5 text-[13px] text-muted">
|
||||
<span data-range>{{ loading ? t('backend::lang.list.loading') : range }}</span>
|
||||
<label v-if="perPageOptions.length > 1" class="flex items-center gap-2">
|
||||
<span>{{ t('backend::lang.list.records_per_page') }}</span>
|
||||
<select
|
||||
data-per-page
|
||||
:value="String(perPage)"
|
||||
class="h-pager rounded-pager border border-border-strong bg-surface px-2 text-text"
|
||||
@change="onPerPage"
|
||||
>
|
||||
<option v-for="option in perPageOptions" :key="option" :value="String(option)">{{ option }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<nav
|
||||
v-if="!loading && total > 0"
|
||||
:aria-label="t('backend::lang.list.pagination')"
|
||||
class="ml-auto flex items-center gap-1.5"
|
||||
>
|
||||
<button type="button" data-page="prev" :class="pagerButton" :disabled="page <= 1" @click="emit('page', page - 1)">
|
||||
<ChevronLeft :size="14" aria-hidden="true" />{{ t('backend::lang.list.prev_page') }}
|
||||
</button>
|
||||
<template v-for="(n, index) in pages" :key="n ?? `gap-${index}`">
|
||||
<span v-if="n === null" aria-hidden="true" class="px-1">…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:data-page="n"
|
||||
:aria-current="n === page ? 'page' : undefined"
|
||||
:class="[pagerButton, n === page ? 'border-transparent bg-sel font-bold' : '']"
|
||||
@click="n !== page && emit('page', n)"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
data-page="next"
|
||||
:class="pagerButton"
|
||||
:disabled="page >= lastPage"
|
||||
@click="emit('page', page + 1)"
|
||||
>
|
||||
{{ t('backend::lang.list.next_page') }}<ChevronRight :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</template>
|
||||
75
admin/src/components/ui/ConfirmDialog.vue
Normal file
75
admin/src/components/ui/ConfirmDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
} from 'reka-ui'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
|
||||
// cancels, focus returns to the trigger). Text only: messages come from the
|
||||
// server and are never rendered as HTML.
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
message: string
|
||||
title?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
}>(),
|
||||
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
|
||||
|
||||
// Closing any other way than the confirm button (Esc, Anuluj) cancels.
|
||||
function onOpenChange(open: boolean): void {
|
||||
emit('update:open', open)
|
||||
if (!open) {
|
||||
emit('cancel')
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirm(): void {
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot :open="open" @update:open="onOpenChange">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<AlertDialogContent
|
||||
data-confirm-dialog
|
||||
class="fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||
>
|
||||
<AlertDialogTitle class="text-[20px] font-bold tracking-[-0.01em]">
|
||||
{{ title ?? t('backend::lang.form.confirm') }}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-muted">{{ message }}</AlertDialogDescription>
|
||||
<div class="flex gap-2.5">
|
||||
<AlertDialogCancel
|
||||
data-action="cancel"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover"
|
||||
>
|
||||
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
|
||||
</AlertDialogCancel>
|
||||
<button
|
||||
type="button"
|
||||
data-action="confirm"
|
||||
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control font-semibold hover:opacity-90"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
31
admin/src/components/ui/confirm.ts
Normal file
31
admin/src/components/ui/confirm.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Promise-based confirmation for ConfirmDialog: `await ask(text)` resolves
|
||||
// true on confirm and false on Anuluj, Esc or a second request.
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ConfirmRequest {
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const request = ref<ConfirmRequest | null>(null)
|
||||
let resolver: ((ok: boolean) => void) | null = null
|
||||
|
||||
function settle(ok: boolean): void {
|
||||
const resolve = resolver
|
||||
resolver = null
|
||||
request.value = null
|
||||
resolve?.(ok)
|
||||
}
|
||||
|
||||
function ask(next: ConfirmRequest): Promise<boolean> {
|
||||
settle(false)
|
||||
request.value = next
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
return { request, ask, confirm: () => settle(true), cancel: () => settle(false) }
|
||||
}
|
||||
@@ -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