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:
@@ -1116,6 +1116,20 @@
|
||||
"recordCount": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
"rowStateDeleted": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
}
|
||||
],
|
||||
"description": "The row-state badge labels (D-12)."
|
||||
},
|
||||
"rowStateDisabled": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
"rowStateNegative": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
"searchPrompt": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
@@ -1132,6 +1146,9 @@
|
||||
"emptySearch",
|
||||
"emptySearchHint",
|
||||
"recordCount",
|
||||
"rowStateDeleted",
|
||||
"rowStateDisabled",
|
||||
"rowStateNegative",
|
||||
"searchPrompt",
|
||||
"selected"
|
||||
],
|
||||
@@ -1148,6 +1165,15 @@
|
||||
"per_page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"row_states": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
@@ -2412,6 +2438,7 @@
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}": {
|
||||
"get": {
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
|
||||
12
admin/src/api/schema.d.ts
vendored
12
admin/src/api/schema.d.ts
vendored
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
admin/src/components/list/RowStateBadges.vue
Normal file
35
admin/src/components/list/RowStateBadges.vue
Normal 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>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -99,6 +99,15 @@
|
||||
},
|
||||
"selected": {
|
||||
"other": "Selected :count"
|
||||
},
|
||||
"rowStateDeleted": {
|
||||
"other": "Deleted"
|
||||
},
|
||||
"rowStateNegative": {
|
||||
"other": "Blocked"
|
||||
},
|
||||
"rowStateDisabled": {
|
||||
"other": "Inactive"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
5
admin/tests/fixtures/roster.list-schema.json
vendored
5
admin/tests/fixtures/roster.list-schema.json
vendored
@@ -33,7 +33,10 @@
|
||||
"emptySearchHint": { "other": "No record matches “:term”. Try another search." },
|
||||
"recordCount": { "one": ":count record", "other": ":count records" },
|
||||
"searchPrompt": { "other": "Search…" },
|
||||
"selected": { "other": ":count selected" }
|
||||
"selected": { "other": ":count selected" },
|
||||
"rowStateDeleted": { "other": "Deleted" },
|
||||
"rowStateNegative": { "other": "Blocked" },
|
||||
"rowStateDisabled": { "other": "Not active" }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
},
|
||||
|
||||
11
admin/tests/fixtures/roster.list.json
vendored
11
admin/tests/fixtures/roster.list.json
vendored
@@ -4,5 +4,14 @@
|
||||
{ "id": 2, "name": "Bob Stone", "email": "bob@example.test" },
|
||||
{ "id": 3, "name": "Cy Young", "email": "cy@example.test" }
|
||||
],
|
||||
"meta": { "page": 1, "per_page": 20, "total": 3, "last_page": 1 }
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 3,
|
||||
"last_page": 1,
|
||||
"row_states": {
|
||||
"2": ["deleted", "negative"],
|
||||
"3": ["disabled", "starred"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"emptySearchHint": { "other": "No widget matches “:term”." },
|
||||
"recordCount": { "one": ":count widget", "other": ":count widgets" },
|
||||
"searchPrompt": { "other": "Search widgets…" },
|
||||
"selected": { "other": "Selected :count" }
|
||||
"selected": { "other": "Selected :count" },
|
||||
"rowStateDeleted": { "other": "Deleted" },
|
||||
"rowStateNegative": { "other": "Blocked" },
|
||||
"rowStateDisabled": { "other": "Inactive" }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
|
||||
// (UI-SPEC S1, D-09) and the record action buttons (UI-SPEC S2, D-10).
|
||||
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
|
||||
// row state badges (UI-SPEC S4, D-12).
|
||||
// Fixtures are neutral acme.roster.* data; no application
|
||||
// names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import RecordActions from '../../src/components/form/RecordActions.vue'
|
||||
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
|
||||
import ToastHost from '../../src/components/ui/Toast.vue'
|
||||
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
@@ -461,3 +463,85 @@ describe('record actions (UI-SPEC S2, D-10)', () => {
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
const rows = (wrapper: VueWrapper) => wrapper.findAll('tbody tr')
|
||||
const firstCell = (wrapper: VueWrapper, index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]!
|
||||
const badges = (wrapper: VueWrapper, index: number) =>
|
||||
firstCell(wrapper, index)
|
||||
.findAll('[data-row-state]')
|
||||
.map((badge) => [badge.attributes('data-row-state'), badge.text()])
|
||||
|
||||
it('leaves a row without states unchanged', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
const row = rows(wrapper)[0]!
|
||||
expect(row.attributes('data-row-states')).toBeUndefined()
|
||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/1')
|
||||
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
||||
})
|
||||
|
||||
it('renders two states as text badges in the fixed order and combines their text styles', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
const row = rows(wrapper)[1]!
|
||||
expect(row.attributes('data-row-states')).toBe('deleted negative')
|
||||
expect(badges(wrapper, 1)).toEqual([
|
||||
['deleted', 'Deleted'],
|
||||
['negative', 'Blocked'],
|
||||
])
|
||||
// deleted + negative: the first-cell text is struck through and danger;
|
||||
// every cell is muted; the badges sit outside the struck text.
|
||||
const cell = firstCell(wrapper, 1)
|
||||
const link = cell.find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
||||
expect(link.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/2')
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
||||
const deleted = cell.find('[data-row-state="deleted"]')
|
||||
expect(deleted.classes()).toEqual(expect.arrayContaining(['border-border-strong', 'text-muted', 'whitespace-nowrap', 'shrink-0']))
|
||||
expect(deleted.classes()).not.toContain('line-through')
|
||||
expect(cell.find('[data-row-state="negative"]').classes()).toEqual(expect.arrayContaining(['bg-danger-soft', 'text-danger']))
|
||||
// The row background is not a state: selection still tints it.
|
||||
expect(row.classes()).not.toContain('bg-danger-soft')
|
||||
})
|
||||
|
||||
it('ignores a state outside the fixed set: no badge, no class, no attribute value', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
const row = rows(wrapper)[2]!
|
||||
expect(row.attributes('data-row-states')).toBe('disabled')
|
||||
expect(badges(wrapper, 2)).toEqual([['disabled', 'Not active']])
|
||||
expect(row.html()).not.toContain('starred')
|
||||
const cell = firstCell(wrapper, 2)
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||
expect(cell.find('a').classes()).not.toContain('line-through')
|
||||
expect(cell.find('[data-row-state="disabled"]').classes()).toEqual(expect.arrayContaining(['bg-subtle', 'text-muted']))
|
||||
|
||||
const only = clone(rosterListFixture)
|
||||
only.meta.row_states = { '1': ['starred', '<b>x</b>'] }
|
||||
const unknown = await mountApp('/acme/roster/people', routes({ [`GET ${LIST}`]: { body: only } }))
|
||||
const first = rows(unknown.wrapper)[0]!
|
||||
expect(first.attributes('data-row-states')).toBeUndefined()
|
||||
expect(first.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(first.html()).not.toContain('starred')
|
||||
expect(firstCell(unknown.wrapper, 0).find('a').classes()).toContain('text-text')
|
||||
})
|
||||
|
||||
it('keeps selection and the row link working for a row with states', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
await rows(wrapper)[1]!.find('td[data-select] [role="checkbox"]').trigger('click')
|
||||
expect(rows(wrapper)[1]!.classes()).toContain('bg-sel')
|
||||
expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('deleted negative')
|
||||
})
|
||||
|
||||
it('renders each known state once, in the fixed order, whatever the input order', () => {
|
||||
const labels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Inactive' }
|
||||
const wrapper = mount(RowStateBadges, { props: { states: ['disabled', 'bogus', 'deleted', 'negative'], labels } })
|
||||
expect(wrapper.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Deleted', 'Blocked', 'Inactive'])
|
||||
const none = mount(RowStateBadges, { props: { states: ['bogus'], labels } })
|
||||
expect(none.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(none.text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user