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
This commit is contained in:
Jakub Zych
2026-10-04 23:45:44 +02:00
parent e0ccced76a
commit 61d5fc72ad
35 changed files with 749 additions and 44 deletions

View File

@@ -608,7 +608,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** List admin records */
/**
* List admin records
* @description meta.row_states carries the states of the page's rows keyed by row id, each a subset of deleted, negative and disabled; it is absent when the controller reports no state.
*/
get: {
parameters: {
query?: {
@@ -4546,6 +4549,10 @@ export interface components {
emptySearch: components["schemas"]["cabana.MessageForms"];
emptySearchHint: components["schemas"]["cabana.MessageForms"];
recordCount: components["schemas"]["cabana.MessageForms"];
/** @description The row-state badge labels (D-12). */
rowStateDeleted: components["schemas"]["cabana.MessageForms"];
rowStateDisabled: components["schemas"]["cabana.MessageForms"];
rowStateNegative: components["schemas"]["cabana.MessageForms"];
searchPrompt: components["schemas"]["cabana.MessageForms"];
selected: components["schemas"]["cabana.MessageForms"];
};
@@ -4553,6 +4560,9 @@ export interface components {
last_page: number;
page: number;
per_page: number;
row_states?: {
[key: string]: string[];
};
total: number;
};
"cabana.ListSchema": {

View File

@@ -6,6 +6,7 @@ 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
@@ -16,7 +17,11 @@ import CellValue from './CellValue.vue'
// 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.
// 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<{
@@ -33,6 +38,10 @@ const props = withDefaults(
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,
@@ -44,6 +53,8 @@ const props = withDefaults(
variant: 'list',
openable: false,
trailing: false,
rowStates: () => ({}),
stateLabels: () => ({}),
},
)
const emit = defineEmits<{
@@ -146,11 +157,34 @@ function initials(value: unknown): string {
.join('')
}
function cellClass(column: ListColumn, columnIndex: number): string {
if (columnIndex === 0) {
return 'font-semibold'
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 relation.value || column.relation ? 'text-muted' : ''
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(() =>
@@ -238,6 +272,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
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)"
>
@@ -257,7 +292,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="cellClass(column, columnIndex)"
: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">
@@ -278,6 +313,15 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</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>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
// Row state badges (UI-SPEC S4, D-12): one text badge per state of a list
// row, in the fixed order deleted, negative, disabled. Colour and
// strike-through alone would fail WCAG 1.4.1, so every state is also said in
// text. The set is fixed: a value the SPA does not know renders nothing, and
// no class is ever built from a server value.
const props = defineProps<{
states: readonly string[]
labels: Record<string, string>
}>()
const STYLES: Record<string, string> = {
deleted: 'border border-border-strong text-muted',
negative: 'bg-danger-soft text-danger',
disabled: 'bg-subtle text-muted',
}
const ORDER = ['deleted', 'negative', 'disabled']
const badges = computed(() => ORDER.filter((state) => props.states.includes(state)))
</script>
<template>
<span v-if="badges.length > 0" class="flex shrink-0 gap-2">
<span
v-for="state in badges"
:key="state"
:data-row-state="state"
:class="STYLES[state]"
class="inline-flex h-6 shrink-0 items-center rounded-pill px-2.5 text-[12px] font-semibold whitespace-nowrap"
>{{ labels[state] ?? state }}</span
>
</span>
</template>

View File

@@ -78,6 +78,13 @@ const toolbarButtons = computed(() =>
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
)
const busyAction = ref<string | null>(null)
// Row states of the current page and their badge labels (D-12).
const rowStates = computed(() => meta.value?.row_states ?? {})
const stateLabels = computed(() => ({
deleted: message(messages.value?.rowStateDeleted),
negative: message(messages.value?.rowStateNegative),
disabled: message(messages.value?.rowStateDisabled),
}))
// Declared bulk actions the admin may run (D-09), in declared order. The
// built-in delete stays the toolbar button and is not part of the menu.
const bulkActions = computed(() => (schema.value?.bulkActions ?? []).filter((action) => action.name !== 'delete'))
@@ -393,6 +400,8 @@ async function onAction(name: string): Promise<void> {
:selected="selected"
:sortable="schema?.showSorting ?? false"
:sort="sort"
:row-states="rowStates"
:state-labels="stateLabels"
@update:selected="(ids: RowId[]) => (selected = ids)"
@sort="onSort"
>