Files
summercms/admin/src/components/list/DataTable.vue
Jakub Zych 61d5fc72ad feat(12.1-01): list row states from one controller call per page
- pact.ListRowStates with the fixed RowState set deleted, negative, disabled
- list response meta.row_states keyed by row id; unknown values dropped
- list messages rowStateDeleted, rowStateNegative, rowStateDisabled
- update writes through the scope the load used, so a soft-deleted record
  a controller includes stays soft-deleted
- DataTable row state badges and text styles
- roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
2026-10-04 23:45:44 +02:00

339 lines
13 KiB
Vue

<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'
import RowStateBadges from './RowStateBadges.vue'
// 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.
// The relation variant (design screen 5) has 56px rows, an initials avatar
// before the first column's text and the remaining columns muted. Phase
// 12.2: with `openable` its rows open (the first cell's text is a button and
// a row click outside other controls does the same), and `trailing` adds a
// 56px last cell filled by the `trailing` slot. Phase 12.1 (UI-SPEC S4): a
// row may carry states from the fixed set deleted, negative, disabled; each
// shows as a text badge after the first cell's text plus a text style, and
// the row background, selection, sorting, links and row click stay as they
// are. A value outside the set is ignored.
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
variant?: 'list' | 'relation'
/** Rows open on click (relation variant): emits `open`. */
openable?: boolean
/** Adds a trailing 56px cell per row, filled by the `trailing` slot. */
trailing?: boolean
/** States per row id (list meta row_states). */
rowStates?: Record<string, string[]>
/** Badge text per state. */
stateLabels?: Record<string, string>
}>(),
{
loading: false,
rowLink: undefined,
selectable: false,
selected: () => [],
sortable: false,
sort: null,
variant: 'list',
openable: false,
trailing: false,
rowStates: () => ({}),
stateLabels: () => ({}),
},
)
const emit = defineEmits<{
'update:selected': [ids: RowId[]]
sort: [column: string]
open: [row: AdminRecord, trigger: HTMLElement | null]
}>()
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, select')) {
return
}
if (props.openable) {
const opener = (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[data-row-open]') ?? null
emit('open', row, opener)
return
}
const to = linkOf(row)
if (to) {
void router.push(to)
}
}
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 toggleAll(): void {
emit('update:selected', headerState.value === 'true' ? [] : [...pageIds.value])
}
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 relation = computed(() => props.variant === 'relation')
const rowHeight = computed(() => (relation.value ? 'h-[56px]' : 'h-row'))
const skeletonRows = computed(() => (relation.value ? 3 : 8))
/** Initials of a cell value for the relation variant's avatar. */
function initials(value: unknown): string {
const text = typeof value === 'string' || typeof value === 'number' ? String(value) : ''
return text
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
const ROW_STATES = ['deleted', 'negative', 'disabled']
/** The row's known states in the fixed order; anything else is dropped. */
function statesOf(row: AdminRecord): string[] {
const id = rowId(row)
const states = id === null ? undefined : props.rowStates[String(id)]
if (!Array.isArray(states)) {
return []
}
return ROW_STATES.filter((state) => states.includes(state))
}
function cellClass(column: ListColumn, columnIndex: number, states: string[] = []): string {
// A deleted or disabled row mutes every cell; the first keeps weight 600.
const muted = states.includes('deleted') || states.includes('disabled')
if (columnIndex === 0) {
return muted ? 'font-semibold text-muted' : 'font-semibold'
}
return muted || relation.value || column.relation ? 'text-muted' : ''
}
/** First-cell text of a row with states: struck through, danger or muted. */
function stateTextClass(states: string[]): string[] {
return [
'min-w-0 truncate',
states.includes('deleted') ? 'line-through' : 'no-underline',
states.includes('negative') ? 'text-danger' : states.includes('deleted') || states.includes('disabled') ? 'text-muted' : 'text-text',
]
}
const span = computed(() =>
Math.max(props.columns.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
)
function openFrom(event: MouseEvent, row: AdminRecord): void {
emit('open', row, event.currentTarget as HTMLElement | null)
}
// 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="relation ? 'min-w-[480px]' : 'min-w-[640px]'" class="w-full border-collapse text-left">
<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"
: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 }}
<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>
<td v-if="trailing" class="w-[56px] pr-5" />
</tr>
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<tr v-for="n in skeletonRows" :key="`skeleton-${n}`" data-skeleton :class="rowHeight" class="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="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
</td>
<td v-if="trailing" class="pr-5" />
</tr>
</template>
<tr v-else-if="rows.length === 0">
<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="[
rowHeight,
isSelected(row) ? 'bg-sel' : linkOf(row) || openable ? 'hover:bg-hover' : '',
linkOf(row) || openable ? 'cursor-pointer' : '',
]"
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
:data-row-states="statesOf(row).length > 0 ? statesOf(row).join(' ') : undefined"
class="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="cellClass(column, columnIndex, statesOf(row))"
class="px-3.5 first:pl-5 last:pr-5"
>
<span v-if="relation && columnIndex === 0" class="flex items-center gap-3">
<span
data-avatar
class="flex size-[30px] shrink-0 items-center justify-center rounded-full bg-subtle text-[12px] font-bold text-text"
aria-hidden="true"
>{{ initials(row[column.key]) }}</span
>
<button
v-if="openable"
type="button"
data-row-open
class="min-w-0 truncate text-left font-semibold text-text"
@click="openFrom($event, row)"
>
<CellValue :column="column" :value="row[column.key]" />
</button>
<CellValue v-else :column="column" :value="row[column.key]" />
</span>
<span v-else-if="columnIndex === 0 && statesOf(row).length > 0" class="flex items-center gap-2">
<RouterLink v-if="linkOf(row)" :to="linkOf(row)!" :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
</RouterLink>
<span v-else :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
</span>
<RowStateBadges :states="statesOf(row)" :labels="stateLabels" />
</span>
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
<CellValue :column="column" :value="row[column.key]" />
</RouterLink>
<CellValue v-else :column="column" :value="row[column.key]" />
</td>
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
<slot name="trailing" :row="row" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>