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">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
import { ArrowDown, ArrowUp, Check, Minus } from '@lucide/vue'
|
||||||
import type { AdminRecord, ListColumn } from '../../api/types'
|
import type { AdminRecord, ListColumn } from '../../api/types'
|
||||||
import { t } from '../../app/i18n'
|
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
|
// Schema-driven table (design screen 3, D-12, D-16): exactly the server's
|
||||||
// read through its column keys (D-16). With rowLink a row opens its record
|
// columns, each record read through its column keys. Optional checkbox
|
||||||
// route; the first cell carries the link for keyboard users.
|
// column with a tri-state header over the current page, sortable headers
|
||||||
const props = defineProps<{
|
// (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[]
|
columns: ListColumn[]
|
||||||
rows: AdminRecord[]
|
rows: AdminRecord[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
emptyText?: string
|
|
||||||
rowLink?: (row: AdminRecord) => string | null
|
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()
|
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 {
|
function linkOf(row: AdminRecord): string | null {
|
||||||
return props.rowLink ? props.rowLink(row) : null
|
return props.rowLink ? props.rowLink(row) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRow(event: MouseEvent, row: AdminRecord): void {
|
function openRow(event: MouseEvent, row: AdminRecord): void {
|
||||||
const target = event.target as HTMLElement | null
|
const target = event.target as HTMLElement | null
|
||||||
if (target?.closest('a, button, input, label')) {
|
if (target?.closest('a, button, input, label, select')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const to = linkOf(row)
|
const to = linkOf(row)
|
||||||
@@ -31,79 +55,152 @@ function openRow(event: MouseEvent, row: AdminRecord): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellText(row: AdminRecord, column: ListColumn): string {
|
const pageIds = computed(() => props.rows.map(rowId).filter((id): id is RowId => id !== null))
|
||||||
return formatValue(row[column.key])
|
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 {
|
function toggleAll(): void {
|
||||||
if (value === null || value === undefined || value === '') {
|
emit('update:selected', headerState.value === 'true' ? [] : [...pageIds.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 rowKey(row: AdminRecord, index: number): string {
|
function toggleRow(row: AdminRecord): void {
|
||||||
const id = row.id
|
const id = rowId(row)
|
||||||
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full min-w-[640px] border-collapse text-left">
|
<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">
|
<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
|
<th
|
||||||
v-for="column in columns"
|
v-for="column in columns"
|
||||||
:key="column.key"
|
:key="column.key"
|
||||||
scope="col"
|
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"
|
||||||
|
>
|
||||||
|
<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 }}
|
{{ 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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody :aria-busy="loading ? 'true' : undefined">
|
<tbody :aria-busy="loading ? 'true' : undefined">
|
||||||
<template v-if="loading">
|
<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">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="rows.length === 0">
|
<tr v-else-if="rows.length === 0">
|
||||||
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
|
<td :colspan="span" class="px-5 py-20 text-center">
|
||||||
{{ emptyText ?? t('backend::lang.list.no_records') }}
|
<slot name="empty">
|
||||||
|
<span class="text-muted">{{ t('backend::lang.list.no_records') }}</span>
|
||||||
|
</slot>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="(row, index) in rows"
|
v-for="(row, index) in rows"
|
||||||
:key="rowKey(row, index)"
|
:key="rowKey(row, index)"
|
||||||
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
|
:class="[isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '', linkOf(row) ? 'cursor-pointer' : '']"
|
||||||
class="h-row border-b border-border"
|
: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)"
|
@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
|
<td
|
||||||
v-for="(column, columnIndex) in columns"
|
v-for="(column, columnIndex) in columns"
|
||||||
:key="column.key"
|
: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"
|
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">
|
<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>
|
</RouterLink>
|
||||||
<template v-else>{{ cellText(row, column) }}</template>
|
<CellValue v-else :column="column" :value="row[column.key]" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Plus, SearchX } from '@lucide/vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
|
import type { AdminRecord, ListMeta, ListQuery, ListSchema } from '../api/types'
|
||||||
import { controllerIdFromParams } from '../app/controllerRoutes'
|
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||||
import { t } from '../app/i18n'
|
import { message, t } from '../app/i18n'
|
||||||
|
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
||||||
import { mapWinterUrl } from '../app/winterUrl'
|
import { mapWinterUrl } from '../app/winterUrl'
|
||||||
import DataTable from '../components/list/DataTable.vue'
|
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 { 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 route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const path = computed(() => ({
|
const path = {
|
||||||
vendor: String(route.params.vendor ?? ''),
|
vendor: String(route.params.vendor ?? ''),
|
||||||
plugin: String(route.params.plugin ?? ''),
|
plugin: String(route.params.plugin ?? ''),
|
||||||
controller: String(route.params.controller ?? ''),
|
controller: String(route.params.controller ?? ''),
|
||||||
}))
|
}
|
||||||
|
const controllerId = controllerIdFromParams(path)
|
||||||
|
const basePath = controllerPath(controllerId) ?? '/'
|
||||||
|
|
||||||
const schema = ref<ListSchema | null>(null)
|
const schema = ref<ListSchema | null>(null)
|
||||||
const rows = ref<AdminRecord[]>([])
|
const rows = ref<AdminRecord[]>([])
|
||||||
const meta = ref<ListMeta | null>(null)
|
const meta = ref<ListMeta | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const failed = ref(false)
|
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(() => {
|
const title = computed(() => {
|
||||||
if (schema.value?.title) {
|
if (schema.value?.title) {
|
||||||
return schema.value.title
|
return schema.value.title
|
||||||
}
|
}
|
||||||
const id = controllerIdFromParams(path.value)
|
const entry = activeEntry(path.vendor, path.plugin)
|
||||||
const entry = activeEntry(path.value.vendor, path.value.plugin)
|
return entry?.sideMenu.find((item) => item.controller === controllerId)?.label ?? controllerId
|
||||||
return entry?.sideMenu.find((item) => item.controller === id)?.label ?? id
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const range = computed(() => {
|
const subtitle = computed(() => (meta.value ? message(messages.value?.recordCount, meta.value.total) : ''))
|
||||||
const m = meta.value
|
|
||||||
if (!m || m.total === 0) {
|
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
|
||||||
return t('backend::lang.list.no_results')
|
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
|
||||||
}
|
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
|
||||||
const from = (m.page - 1) * m.per_page + 1
|
|
||||||
const to = Math.min(m.page * m.per_page, m.total)
|
const perPageOptions = computed(() => {
|
||||||
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
|
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). */
|
/** Record route of a row when the list declares a recordUrl (D-10). */
|
||||||
function rowLink(row: AdminRecord): string | null {
|
function rowLink(row: AdminRecord): string | null {
|
||||||
@@ -49,53 +80,224 @@ function rowLink(row: AdminRecord): string | null {
|
|||||||
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
|
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
|
||||||
return null
|
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
|
let generation = 0
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function loadList(): Promise<void> {
|
||||||
const current = ++generation
|
const current = ++generation
|
||||||
loading.value = true
|
loading.value = true
|
||||||
failed.value = false
|
failed.value = false
|
||||||
const params = { path: path.value }
|
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||||
const [schemaResult, listResult] = await Promise.all([
|
|
||||||
api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params }),
|
|
||||||
api.GET('/{vendor}/{plugin}/{controller}', { params }),
|
|
||||||
])
|
|
||||||
if (current !== generation) {
|
if (current !== generation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
schema.value = schemaResult.data?.data ?? null
|
rows.value = result.data?.data ?? []
|
||||||
rows.value = listResult.data?.data ?? []
|
meta.value = result.data?.meta ?? null
|
||||||
meta.value = listResult.data?.meta ?? null
|
failed.value = !result.data
|
||||||
failed.value = !schemaResult.data || !listResult.data
|
|
||||||
loading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col gap-5">
|
<section class="flex flex-col gap-5">
|
||||||
<header>
|
<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>
|
<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>
|
</header>
|
||||||
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
|
<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') }}
|
{{ t('backend::lang.list.load_failed') }}
|
||||||
</p>
|
</p>
|
||||||
<DataTable
|
<DataTable
|
||||||
v-else
|
v-else
|
||||||
:columns="schema?.columns ?? []"
|
:columns="schema?.columns ?? []"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:loading="loading"
|
:loading="loading || !schema"
|
||||||
:empty-text="schema?.noRecordsMessage"
|
|
||||||
:row-link="rowLink"
|
:row-link="rowLink"
|
||||||
/>
|
:selectable="schema?.showCheckboxes ?? false"
|
||||||
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
|
:selected="selected"
|
||||||
{{ loading ? t('backend::lang.list.loading') : range }}
|
:sortable="schema?.showSorting ?? false"
|
||||||
</footer>
|
: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>
|
</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"
|
||||||
|
@confirm="confirm.confirm"
|
||||||
|
@cancel="confirm.cancel"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
21
admin/tests/fixtures/lang.json
vendored
21
admin/tests/fixtures/lang.json
vendored
@@ -117,12 +117,27 @@
|
|||||||
"backend::lang.list.column_updated": {
|
"backend::lang.list.column_updated": {
|
||||||
"other": "Zaktualizowano"
|
"other": "Zaktualizowano"
|
||||||
},
|
},
|
||||||
|
"backend::lang.list.delete_failed": {
|
||||||
|
"other": "Nie udało się usunąć zaznaczonych rekordów."
|
||||||
|
},
|
||||||
"backend::lang.list.delete_selected": {
|
"backend::lang.list.delete_selected": {
|
||||||
"other": "Usuń zaznaczone"
|
"other": "Usuń zaznaczone"
|
||||||
},
|
},
|
||||||
"backend::lang.list.empty_value": {
|
"backend::lang.list.empty_value": {
|
||||||
"other": "—"
|
"other": "—"
|
||||||
},
|
},
|
||||||
|
"backend::lang.list.filter_all": {
|
||||||
|
"other": "Wszystkie"
|
||||||
|
},
|
||||||
|
"backend::lang.list.filter_from": {
|
||||||
|
"other": "Od"
|
||||||
|
},
|
||||||
|
"backend::lang.list.filter_to": {
|
||||||
|
"other": "Do"
|
||||||
|
},
|
||||||
|
"backend::lang.list.filters": {
|
||||||
|
"other": "Filtry"
|
||||||
|
},
|
||||||
"backend::lang.list.load_failed": {
|
"backend::lang.list.load_failed": {
|
||||||
"other": "Nie udało się wczytać listy."
|
"other": "Nie udało się wczytać listy."
|
||||||
},
|
},
|
||||||
@@ -138,6 +153,9 @@
|
|||||||
"backend::lang.list.no_results": {
|
"backend::lang.list.no_results": {
|
||||||
"other": "0 wyników"
|
"other": "0 wyników"
|
||||||
},
|
},
|
||||||
|
"backend::lang.list.pagination": {
|
||||||
|
"other": "Stronicowanie"
|
||||||
|
},
|
||||||
"backend::lang.list.pagination_range": {
|
"backend::lang.list.pagination_range": {
|
||||||
"other": ":from–:to z :total"
|
"other": ":from–:to z :total"
|
||||||
},
|
},
|
||||||
@@ -153,6 +171,9 @@
|
|||||||
"many": ":count wyników",
|
"many": ":count wyników",
|
||||||
"other": ":count wyniku"
|
"other": ":count wyniku"
|
||||||
},
|
},
|
||||||
|
"backend::lang.list.search": {
|
||||||
|
"other": "Szukaj"
|
||||||
|
},
|
||||||
"backend::lang.list.search_prompt": {
|
"backend::lang.list.search_prompt": {
|
||||||
"other": "Szukaj…"
|
"other": "Szukaj…"
|
||||||
},
|
},
|
||||||
|
|||||||
55
admin/tests/fixtures/widgets.list-schema.json
vendored
55
admin/tests/fixtures/widgets.list-schema.json
vendored
@@ -8,38 +8,35 @@
|
|||||||
"showCheckboxes": true,
|
"showCheckboxes": true,
|
||||||
"showSorting": true,
|
"showSorting": true,
|
||||||
"searchTerm": "",
|
"searchTerm": "",
|
||||||
"toolbarButtons": [
|
"recordUrl": "acme/demo/widgets/update/:id",
|
||||||
"create"
|
"toolbarButtons": ["create", "delete"],
|
||||||
],
|
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||||
"key": "name",
|
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
|
||||||
"label": "Name",
|
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" },
|
||||||
"searchable": true,
|
{ "key": "created_at", "label": "Created", "searchable": false, "sortable": true, "type": "datetime" },
|
||||||
"sortable": true
|
{ "key": "active", "label": "Active", "searchable": false, "sortable": false, "type": "switch" }
|
||||||
},
|
],
|
||||||
{
|
"filters": [
|
||||||
"key": "code",
|
{ "name": "active", "label": "Active", "type": "switch", "column": "active", "trueValue": true, "falseValue": false },
|
||||||
"label": "Code",
|
{ "name": "created", "label": "Created", "type": "daterange", "column": "created_at" },
|
||||||
"searchable": true,
|
{ "name": "maker", "label": "Maker", "type": "scope", "scope": "byMaker", "modelClass": "Acme\\Demo\\Models\\Maker", "nameFrom": "name" }
|
||||||
"sortable": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "tags",
|
|
||||||
"label": "Tags",
|
|
||||||
"searchable": false,
|
|
||||||
"sortable": false,
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "tags",
|
|
||||||
"select": "name"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"filters": [],
|
|
||||||
"rowActions": [],
|
"rowActions": [],
|
||||||
"bulkActions": [],
|
"bulkActions": [],
|
||||||
"recordUrl": "acme/demo/widgets/update/:id"
|
"messages": {
|
||||||
|
"create": { "other": "New widget" },
|
||||||
|
"deleteConfirm": { "one": "Delete :count widget?", "other": "Delete :count widgets?" },
|
||||||
|
"deleteSelected": { "other": "Delete selected" },
|
||||||
|
"deleted": { "one": "Deleted :count widget", "other": "Deleted :count widgets" },
|
||||||
|
"empty": { "other": "No widgets yet." },
|
||||||
|
"emptySearch": { "other": "Nothing found" },
|
||||||
|
"emptySearchHint": { "other": "No widget matches “:term”." },
|
||||||
|
"recordCount": { "one": ":count widget", "other": ":count widgets" },
|
||||||
|
"searchPrompt": { "other": "Search widgets…" },
|
||||||
|
"selected": { "other": "Selected :count" }
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": { "locale": "en" }
|
||||||
"locale": "en"
|
},
|
||||||
}
|
"meta": { "locale": "en" }
|
||||||
}
|
}
|
||||||
|
|||||||
4
admin/tests/fixtures/widgets.list.json
vendored
4
admin/tests/fixtures/widgets.list.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"] },
|
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"], "created_at": "2026-09-21T14:32:00", "active": true },
|
||||||
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [] }
|
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [], "created_at": null, "active": false }
|
||||||
],
|
],
|
||||||
"meta": { "page": 1, "per_page": 20, "total": 2, "last_page": 1 }
|
"meta": { "page": 1, "per_page": 20, "total": 2, "last_page": 1 }
|
||||||
}
|
}
|
||||||
|
|||||||
9
admin/tests/fixtures/widgets.options.json
vendored
Normal file
9
admin/tests/fixtures/widgets.options.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"maker": {
|
||||||
|
"data": [
|
||||||
|
{ "value": "1", "label": "Acme Works" },
|
||||||
|
{ "value": "2", "label": "Bolt & Co" }
|
||||||
|
],
|
||||||
|
"meta": { "locale": "en" }
|
||||||
|
}
|
||||||
|
}
|
||||||
349
admin/tests/smoke/list.smoke.test.ts
Normal file
349
admin/tests/smoke/list.smoke.test.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { setBundle } from '../../src/app/i18n'
|
||||||
|
import { parseListQuery, toListQuery } from '../../src/app/listQuery'
|
||||||
|
import { clearUser } from '../../src/state/useAuth'
|
||||||
|
import { setNavigation } from '../../src/state/useNavigation'
|
||||||
|
import { clearToasts } from '../../src/state/useToasts'
|
||||||
|
import lang from '../fixtures/lang.json'
|
||||||
|
import listSchema from '../fixtures/widgets.list-schema.json'
|
||||||
|
import listRows from '../fixtures/widgets.list.json'
|
||||||
|
import options from '../fixtures/widgets.options.json'
|
||||||
|
import { API, mountApp, queryOf, requestsTo, type Route } from '../helpers'
|
||||||
|
|
||||||
|
const LIST = `${API}/acme/demo/widgets`
|
||||||
|
|
||||||
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||||
|
return {
|
||||||
|
[`GET ${LIST}/schema/list`]: { body: listSchema },
|
||||||
|
[`GET ${LIST}`]: { body: listRows },
|
||||||
|
[`GET ${LIST}/filters/maker/options`]: { body: options.maker },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaWith(patch: Record<string, unknown>) {
|
||||||
|
return { ...listSchema, data: { ...listSchema.data, ...patch } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function listGets(calls: Request[]): Request[] {
|
||||||
|
return requestsTo(calls, 'GET', LIST)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastListQuery(calls: Request[]): URLSearchParams {
|
||||||
|
const gets = listGets(calls)
|
||||||
|
return queryOf(gets[gets.length - 1]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearUser()
|
||||||
|
setNavigation([])
|
||||||
|
clearToasts()
|
||||||
|
setBundle(lang.data, 'pl')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('list query', () => {
|
||||||
|
it('round-trips search, sort, paging and filters and drops malformed values', () => {
|
||||||
|
const parsed = parseListQuery({
|
||||||
|
search: ' blue ',
|
||||||
|
sort: 'name',
|
||||||
|
dir: 'sideways',
|
||||||
|
page: '0',
|
||||||
|
per_page: 'x',
|
||||||
|
'filter[active]': 'true',
|
||||||
|
'filter[created]': '',
|
||||||
|
})
|
||||||
|
expect(parsed).toEqual({ search: 'blue', sort: 'name', dir: 'asc', filter: { active: 'true' } })
|
||||||
|
expect(toListQuery({ ...parsed, page: 3, per_page: 50 })).toEqual({
|
||||||
|
search: 'blue',
|
||||||
|
sort: 'name',
|
||||||
|
dir: 'asc',
|
||||||
|
page: '3',
|
||||||
|
per_page: '50',
|
||||||
|
'filter[active]': 'true',
|
||||||
|
})
|
||||||
|
expect(toListQuery({ page: 1 })).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('list screen', () => {
|
||||||
|
it('renders the heading, toolbar buttons in their places and typed cells', async () => {
|
||||||
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||||
|
|
||||||
|
expect(wrapper.find('h1').text()).toBe('Widgets')
|
||||||
|
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
|
||||||
|
const create = wrapper.find('header [data-action="create"]')
|
||||||
|
expect(create.text()).toBe('New widget')
|
||||||
|
expect(create.attributes('href')).toBe('/acme/demo/widgets/create')
|
||||||
|
expect(wrapper.find('[data-list-search]').attributes('placeholder')).toBe('Search widgets…')
|
||||||
|
const del = wrapper.find('[data-action="delete"]')
|
||||||
|
expect(del.text()).toBe('Delete selected')
|
||||||
|
expect(del.attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
|
const [blue, green] = wrapper.findAll('tbody tr')
|
||||||
|
const blueCells = blue!.findAll('td:not([data-select])')
|
||||||
|
expect(blueCells[3]!.text()).toBe('2026-09-21 14:32')
|
||||||
|
expect(blueCells[3]!.find('span').classes()).toContain('tabular-nums')
|
||||||
|
expect(blueCells[4]!.find('[data-switch="true"]').text()).toBe('Tak')
|
||||||
|
expect(blueCells[4]!.find('[data-switch="true"] svg').exists()).toBe(true)
|
||||||
|
const greenCells = green!.findAll('td:not([data-select])')
|
||||||
|
expect(greenCells[2]!.find('[data-empty]').text()).toBe('—')
|
||||||
|
expect(greenCells[3]!.find('[data-empty]').exists()).toBe(true)
|
||||||
|
expect(greenCells[4]!.find('[data-switch="false"]').text()).toBe('Nie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cycles sorting asc, desc, none with aria-sort and the URL', async () => {
|
||||||
|
const { wrapper, router, calls } = await mountApp('/acme/demo/widgets', routes())
|
||||||
|
const nameHeader = () => wrapper.findAll('th:not([data-select])')[0]!
|
||||||
|
|
||||||
|
expect(nameHeader().attributes('aria-sort')).toBe('none')
|
||||||
|
expect(wrapper.findAll('th:not([data-select])')[2]!.attributes('aria-sort')).toBeUndefined()
|
||||||
|
|
||||||
|
await wrapper.find('[data-sort="name"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toMatchObject({ sort: 'name', dir: 'asc' })
|
||||||
|
expect(nameHeader().attributes('aria-sort')).toBe('ascending')
|
||||||
|
expect(nameHeader().classes()).toContain('text-text')
|
||||||
|
expect(lastListQuery(calls).get('sort')).toBe('name')
|
||||||
|
expect(lastListQuery(calls).get('dir')).toBe('asc')
|
||||||
|
|
||||||
|
await wrapper.find('[data-sort="name"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toMatchObject({ sort: 'name', dir: 'desc' })
|
||||||
|
expect(nameHeader().attributes('aria-sort')).toBe('descending')
|
||||||
|
|
||||||
|
await wrapper.find('[data-sort="name"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query.sort).toBeUndefined()
|
||||||
|
expect(nameHeader().attributes('aria-sort')).toBe('none')
|
||||||
|
expect(lastListQuery(calls).has('sort')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('debounces search by 300 ms and resets the page', async () => {
|
||||||
|
const { wrapper, router, calls } = await mountApp('/acme/demo/widgets?page=3', routes())
|
||||||
|
expect(lastListQuery(calls).get('page')).toBe('3')
|
||||||
|
|
||||||
|
await wrapper.find('[data-list-search]').setValue('blue')
|
||||||
|
await wait(100)
|
||||||
|
expect(router.currentRoute.value.query.search).toBeUndefined()
|
||||||
|
|
||||||
|
await wait(300)
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({ search: 'blue' })
|
||||||
|
expect(lastListQuery(calls).get('search')).toBe('blue')
|
||||||
|
expect(lastListQuery(calls).has('page')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects the page with a tri-state header and clears the selection on change', async () => {
|
||||||
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||||
|
const header = () => wrapper.find('th[data-select] [role="checkbox"]')
|
||||||
|
const rowBoxes = () => wrapper.findAll('td[data-select] [role="checkbox"]')
|
||||||
|
|
||||||
|
expect(header().attributes('aria-checked')).toBe('false')
|
||||||
|
await rowBoxes()[0]!.trigger('click')
|
||||||
|
expect(header().attributes('aria-checked')).toBe('mixed')
|
||||||
|
expect(header().find('svg').exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll('tbody tr')[0]!.classes()).toContain('bg-sel')
|
||||||
|
expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 1')
|
||||||
|
|
||||||
|
await header().trigger('click')
|
||||||
|
expect(header().attributes('aria-checked')).toBe('true')
|
||||||
|
expect(rowBoxes().map((box) => box.attributes('aria-checked'))).toEqual(['true', 'true'])
|
||||||
|
|
||||||
|
await header().trigger('click')
|
||||||
|
expect(header().attributes('aria-checked')).toBe('false')
|
||||||
|
|
||||||
|
await rowBoxes()[1]!.trigger('click')
|
||||||
|
await wrapper.find('[data-sort="code"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(header().attributes('aria-checked')).toBe('false')
|
||||||
|
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms bulk delete with the plural message and posts the ids', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/demo/widgets',
|
||||||
|
routes({ [`POST ${LIST}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } } }),
|
||||||
|
)
|
||||||
|
const gets = listGets(calls).length
|
||||||
|
|
||||||
|
await wrapper.find('th[data-select] [role="checkbox"]').trigger('click')
|
||||||
|
const del = wrapper.find('[data-action="delete"]')
|
||||||
|
expect(del.attributes('disabled')).toBeUndefined()
|
||||||
|
expect(del.classes()).toContain('text-danger')
|
||||||
|
await del.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[data-confirm-dialog]')!
|
||||||
|
expect(dialog.getAttribute('role')).toBe('alertdialog')
|
||||||
|
expect(dialog.textContent).toContain('Delete 2 widgets?')
|
||||||
|
;(dialog.querySelector('[data-action="confirm"]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const [post] = requestsTo(calls, 'POST', `${LIST}/bulk-delete`)
|
||||||
|
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||||
|
expect(await post!.json()).toEqual({ ids: [1, 2] })
|
||||||
|
expect(wrapper.find('[role="status"]').text()).toContain('Deleted 2 widgets')
|
||||||
|
expect(listGets(calls).length).toBe(gets + 1)
|
||||||
|
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends nothing when the delete confirmation is cancelled and toasts a 409', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/demo/widgets',
|
||||||
|
routes({
|
||||||
|
[`POST ${LIST}/bulk-delete`]: {
|
||||||
|
status: 409,
|
||||||
|
body: { error: { code: 'conflict', message: 'Widgets are still in use.', details: {} } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await wrapper.findAll('td[data-select] [role="checkbox"]')[0]!.trigger('click')
|
||||||
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete 1 widget?')
|
||||||
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="cancel"]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(requestsTo(calls, 'POST', `${LIST}/bulk-delete`)).toHaveLength(0)
|
||||||
|
|
||||||
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLButtonElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(requestsTo(calls, 'POST', `${LIST}/bulk-delete`)).toHaveLength(1)
|
||||||
|
const toast = wrapper.find('[data-tone="danger"]')
|
||||||
|
expect(toast.text()).toContain('Widgets are still in use.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes each filter shape to filter[name] and resets the page', async () => {
|
||||||
|
const { wrapper, router, calls } = await mountApp('/acme/demo/widgets?page=2', routes())
|
||||||
|
const bar = wrapper.find('[data-filter-bar]')
|
||||||
|
expect(bar.exists()).toBe(true)
|
||||||
|
|
||||||
|
const active = bar.find('select[data-filter="active"]')
|
||||||
|
expect(active.findAll('option').map((option) => option.text())).toEqual(['Wszystkie', 'Tak', 'Nie'])
|
||||||
|
await active.setValue('true')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({ 'filter[active]': 'true' })
|
||||||
|
expect(lastListQuery(calls).get('filter[active]')).toBe('true')
|
||||||
|
|
||||||
|
await bar.find('[data-filter="created-from"]').setValue('2026-09-01')
|
||||||
|
await bar.find('[data-filter="created-from"]').trigger('change')
|
||||||
|
expect(router.currentRoute.value.query['filter[created]']).toBeUndefined()
|
||||||
|
await bar.find('[data-filter="created-to"]').setValue('2026-09-30')
|
||||||
|
await bar.find('[data-filter="created-to"]').trigger('change')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query['filter[created]']).toBe('2026-09-01..2026-09-30')
|
||||||
|
|
||||||
|
const maker = wrapper.find('select[data-filter="maker"]')
|
||||||
|
expect(requestsTo(calls, 'GET', `${LIST}/filters/maker/options`)).toHaveLength(1)
|
||||||
|
expect(maker.findAll('option').map((option) => option.text())).toEqual(['Wszystkie', 'Acme Works', 'Bolt & Co'])
|
||||||
|
await maker.setValue('2')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({
|
||||||
|
'filter[active]': 'true',
|
||||||
|
'filter[created]': '2026-09-01..2026-09-30',
|
||||||
|
'filter[maker]': '2',
|
||||||
|
})
|
||||||
|
const sent = lastListQuery(calls)
|
||||||
|
expect(sent.get('filter[maker]')).toBe('2')
|
||||||
|
expect(sent.get('filter[created]')).toBe('2026-09-01..2026-09-30')
|
||||||
|
|
||||||
|
await wrapper.find('select[data-filter="active"]').setValue('')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query['filter[active]']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the filter bar without filters and the per-page select with one option', async () => {
|
||||||
|
const { wrapper, router } = await mountApp(
|
||||||
|
'/acme/demo/widgets',
|
||||||
|
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith({ filters: [] }) } }),
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-filter-bar]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-per-page]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-range]').text()).toBe('1–2 z 2')
|
||||||
|
expect(wrapper.find('[aria-current="page"][data-page="1"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the schema per-page options and pages with ellipsis', async () => {
|
||||||
|
const { wrapper, router, calls } = await mountApp(
|
||||||
|
'/acme/demo/widgets?page=5',
|
||||||
|
routes({
|
||||||
|
[`GET ${LIST}/schema/list`]: { body: schemaWith({ perPageOptions: [10, 20, 50] }) },
|
||||||
|
[`GET ${LIST}`]: { body: { ...listRows, meta: { page: 5, per_page: 20, total: 400, last_page: 20 } } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const perPage = wrapper.find('[data-per-page]')
|
||||||
|
expect(perPage.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50'])
|
||||||
|
expect(wrapper.find('[data-range]').text()).toBe('81–100 z 400')
|
||||||
|
const pager = wrapper.findAll('nav [data-page]').map((button) => button.attributes('data-page'))
|
||||||
|
expect(pager).toEqual(['prev', '1', '4', '5', '6', '20', 'next'])
|
||||||
|
expect(wrapper.find('[data-page="5"]').attributes('aria-current')).toBe('page')
|
||||||
|
|
||||||
|
await wrapper.find('[data-page="6"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({ page: '6' })
|
||||||
|
|
||||||
|
await perPage.setValue('50')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({ per_page: '50' })
|
||||||
|
expect(lastListQuery(calls).get('per_page')).toBe('50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty message and the plural zero results without a pager', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/demo/widgets',
|
||||||
|
routes({ [`GET ${LIST}`]: { body: { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } } }),
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-empty-list]').text()).toBe('No widgets yet.')
|
||||||
|
expect(wrapper.find('[data-range]').text()).toBe('0 wyników')
|
||||||
|
expect(wrapper.find('nav [data-page]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty-search state with the term and clears the search', async () => {
|
||||||
|
const { wrapper, router } = await mountApp(
|
||||||
|
'/acme/demo/widgets?search=zzz',
|
||||||
|
routes({ [`GET ${LIST}`]: { body: { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } } }),
|
||||||
|
)
|
||||||
|
const state = wrapper.find('[data-empty-search]')
|
||||||
|
expect(state.text()).toContain('Nothing found')
|
||||||
|
expect(state.text()).toContain('No widget matches “zzz”.')
|
||||||
|
expect(state.find('svg').exists()).toBe(true)
|
||||||
|
expect((wrapper.find('[data-list-search]').element as HTMLInputElement).value).toBe('zzz')
|
||||||
|
|
||||||
|
await state.find('[data-action="clear-search"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.query).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows eight skeleton rows while loading and keeps the toolbar and footer', async () => {
|
||||||
|
let release: (() => void) | undefined
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/demo/widgets',
|
||||||
|
routes({
|
||||||
|
[`GET ${LIST}`]: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
release = () => resolve({ body: listRows })
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8)
|
||||||
|
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…')
|
||||||
|
expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true')
|
||||||
|
|
||||||
|
release?.()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(0)
|
||||||
|
expect(wrapper.findAll('tbody tr')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -189,8 +189,11 @@ describe('navigation shell and list', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(wrapper.find('h1').text()).toBe('Widgets')
|
expect(wrapper.find('h1').text()).toBe('Widgets')
|
||||||
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
|
// The checkbox column (data-select) is not a schema column.
|
||||||
const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
|
expect(wrapper.findAll('th:not([data-select])').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags', 'Created', 'Active'])
|
||||||
|
const rows = wrapper
|
||||||
|
.findAll('tbody tr')
|
||||||
|
.map((tr) => tr.findAll('td:not([data-select])').map((td) => td.text()).slice(0, 3))
|
||||||
expect(rows).toEqual([
|
expect(rows).toEqual([
|
||||||
['Blue widget', 'W-01', 'small, round'],
|
['Blue widget', 'W-01', 'small, round'],
|
||||||
['Green widget', 'W-02', '—'],
|
['Green widget', 'W-02', '—'],
|
||||||
|
|||||||
1
boardwalk/dist/assets/index-BB795npz.js
vendored
1
boardwalk/dist/assets/index-BB795npz.js
vendored
File diff suppressed because one or more lines are too long
3
boardwalk/dist/assets/index-CAZobGnD.js
vendored
Normal file
3
boardwalk/dist/assets/index-CAZobGnD.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-VVVgyQoR.css
vendored
1
boardwalk/dist/assets/index-VVVgyQoR.css
vendored
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-W7axD3vY.css
vendored
Normal file
1
boardwalk/dist/assets/index-W7axD3vY.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
boardwalk/dist/index.html
vendored
4
boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-BB795npz.js"></script>
|
<script type="module" crossorigin src="./assets/index-CAZobGnD.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-VVVgyQoR.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-W7axD3vY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ list:
|
|||||||
select_row: Select row
|
select_row: Select row
|
||||||
sort_ascending: Sort ascending
|
sort_ascending: Sort ascending
|
||||||
sort_descending: Sort descending
|
sort_descending: Sort descending
|
||||||
|
search: Search
|
||||||
|
delete_failed: The selected records could not be deleted.
|
||||||
|
filters: Filters
|
||||||
|
filter_all: All
|
||||||
|
filter_from: From
|
||||||
|
filter_to: To
|
||||||
|
pagination: Pagination
|
||||||
form:
|
form:
|
||||||
save: Save
|
save: Save
|
||||||
save_and_close: Save and close
|
save_and_close: Save and close
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ list:
|
|||||||
select_row: Zaznacz wiersz
|
select_row: Zaznacz wiersz
|
||||||
sort_ascending: Sortuj rosnąco
|
sort_ascending: Sortuj rosnąco
|
||||||
sort_descending: Sortuj malejąco
|
sort_descending: Sortuj malejąco
|
||||||
|
search: Szukaj
|
||||||
|
delete_failed: Nie udało się usunąć zaznaczonych rekordów.
|
||||||
|
filters: Filtry
|
||||||
|
filter_all: Wszystkie
|
||||||
|
filter_from: Od
|
||||||
|
filter_to: Do
|
||||||
|
pagination: Stronicowanie
|
||||||
form:
|
form:
|
||||||
save: Zapisz
|
save: Zapisz
|
||||||
save_and_close: Zapisz i zamknij
|
save_and_close: Zapisz i zamknij
|
||||||
|
|||||||
Reference in New Issue
Block a user