feat(18-01): money list column, danger record actions, preview Edit rule and table kit (task 2)

- columns.yaml type: money with currency or currencyFrom (exactly one),
  never searchable, not sortable by default; every list row carries the
  currencyFrom column's value under its own key; the schema serves
  currency and currencyFrom on the column
- pact.AdminRecordAction.Danger, served as danger on cabana.RecordAction
  (absent when false); RecordActions.vue passes it to the confirm dialog
- CellValue money kind through formatMoney with the row's code, the em
  dash when empty and the amount alone without a code; DataTable aligns
  money headers and cells to the right
- PreviewView renders Edit only when a registered value field allows
  update (F6)
- main.css .summer-table* and .summer-stat__note kit classes (F7) over
  --c-* variables; cabana README kit rows
- fixture record actions void (danger) and resend; TestMoneyListCompile,
  TestMoneyListSmoke and TestRecordActionDangerSmoke; regenerated OpenAPI
  and TS types; rebuilt dist; lists-and-filters and admin-controllers docs
This commit is contained in:
Jakub Zych
2026-10-13 11:22:36 +02:00
parent 20385e53ee
commit 0c8605123e
29 changed files with 615 additions and 56 deletions

View File

@@ -1719,6 +1719,13 @@
},
"cabana.ListColumn": {
"properties": {
"currency": {
"description": "Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom\nnames the model column that holds each row's code instead. Every list\nrow carries the CurrencyFrom column's value under its own key, so the\nadmin never guesses a currency (D-07).",
"type": "string"
},
"currencyFrom": {
"type": "string"
},
"invisible": {
"description": "Invisible marks a column that is searched and sorted on the server but\nnot shown: the admin renders no header or cell for it and list rows do\nnot carry its value (columns.yaml invisible).",
"type": "boolean"
@@ -2209,6 +2216,10 @@
"confirm": {
"type": "string"
},
"danger": {
"description": "Danger marks an action whose confirm dialog takes the danger variant\n(pact.AdminRecordAction.Danger); an ordinary action omits the key.",
"type": "boolean"
},
"label": {
"type": "string"
},

View File

@@ -5802,6 +5802,14 @@ export interface components {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
"cabana.ListColumn": {
/**
* @description Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom
* names the model column that holds each row's code instead. Every list
* row carries the CurrencyFrom column's value under its own key, so the
* admin never guesses a currency (D-07).
*/
currency?: string;
currencyFrom?: string;
/**
* @description Invisible marks a column that is searched and sorted on the server but
* not shown: the admin renders no header or cell for it and list rows do
@@ -5949,6 +5957,11 @@ export interface components {
};
"cabana.RecordAction": {
confirm?: string;
/**
* @description Danger marks an action whose confirm dialog takes the danger variant
* (pact.AdminRecordAction.Danger); an ordinary action omits the key.
*/
danger?: boolean;
label: string;
name: string;
};

View File

@@ -14,7 +14,8 @@ import { useConfirm } from '../ui/confirm'
// that is not listed is not rendered. Every action asks for confirmation; the
// dialog stays open and busy while the POST runs, and every button here is
// disabled meanwhile. Labels, confirm texts and messages come from the server
// and are rendered as text.
// and are rendered as text. A `danger` action (UI-SPEC F5) turns the dialog's
// confirm button into the danger variant; the footer button stays outline.
const props = withDefaults(
defineProps<{
source: ControllerParams
@@ -49,7 +50,7 @@ async function run(action: RecordAction): Promise<void> {
{
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
confirmLabel: action.label,
danger: false,
danger: action.danger,
},
async () => {
running.value = true

View File

@@ -2,7 +2,8 @@
import { computed } from 'vue'
import { ArrowUpRight } from '@lucide/vue'
import type { ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
import { currentLocale, t } from '../../app/i18n'
import { formatMoney } from '../../app/money'
// 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
@@ -10,7 +11,11 @@ import { t } from '../../app/i18n'
// Phase 12.2: a `date` column shows the stored YYYY-MM-DD and a `time`
// column the stored HH:mm, both as strings: a calendar date or a time of day
// has no zone, so it never goes through the global Date (Pitfall 12).
const props = defineProps<{ column: ListColumn; value: unknown }>()
// Phase 18: a `money` column formats the row's decimal string through Intl
// with the column's currency or the row's currencyFrom value (UI-SPEC F1);
// without a code it shows the amount alone. The string never becomes a
// number.
const props = defineProps<{ column: ListColumn; value: unknown; row?: Record<string, unknown> }>()
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''
@@ -72,10 +77,29 @@ function truthy(value: unknown): boolean {
return Boolean(value)
}
/** The row's currency code for a money column: the literal or the sibling value. */
function currencyOf(): string {
if (props.column.currencyFrom) {
const code = props.row?.[props.column.currencyFrom]
return typeof code === 'string' ? code.trim() : ''
}
return props.column.currency ?? ''
}
function money(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') {
return ''
}
return formatMoney(value.trim(), currencyOf(), currentLocale.value, 4)
}
const kind = computed(() => {
if (props.column.type === 'switch') {
return 'switch'
}
if (props.column.type === 'money') {
return 'money'
}
if (props.column.type === 'datetime') {
return 'datetime'
}
@@ -96,6 +120,8 @@ const display = computed(() => {
return storedDate(props.value)
case 'time':
return storedTime(props.value)
case 'money':
return money(props.value)
default:
return text(props.value)
}
@@ -120,5 +146,6 @@ const display = computed(() => {
</template>
<span v-else-if="display === ''" class="text-muted" data-empty>—</span>
<span v-else-if="kind === 'datetime' || kind === 'date' || kind === 'time'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
<span v-else-if="kind === 'money'" data-money class="tabular-nums whitespace-nowrap">{{ display }}</span>
<template v-else>{{ display }}</template>
</template>

View File

@@ -172,10 +172,16 @@ function statesOf(row: AdminRecord): string[] {
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')
const align = alignClass(column)
if (columnIndex === 0) {
return muted ? 'font-semibold text-muted' : 'font-semibold'
return [muted ? 'font-semibold text-muted' : 'font-semibold', align].filter(Boolean).join(' ')
}
return muted || relation.value || column.relation ? 'text-muted' : ''
return [muted || relation.value || column.relation ? 'text-muted' : '', align].filter(Boolean).join(' ')
}
/** A money column aligns its header and cells to the right (UI-SPEC F1). */
function alignClass(column: ListColumn): string {
return column.type === 'money' ? 'text-right' : ''
}
/** First-cell text of a row with states: struck through, danger or muted. */
@@ -228,7 +234,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
:key="column.key"
scope="col"
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
:class="sortState(column) ? 'text-text' : 'text-muted'"
:class="[sortState(column) ? 'text-text' : 'text-muted', alignClass(column)]"
class="px-3.5 text-[12px] font-semibold first:pl-5 last:pr-5"
>
<button
@@ -313,23 +319,23 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
class="min-w-0 truncate text-left font-semibold text-text"
@click="openFrom($event, row)"
>
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</button>
<CellValue v-else :column="column" :value="row[column.key]" />
<CellValue v-else :column="column" :value="row[column.key]" :row="row" />
</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]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</RouterLink>
<span v-else :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</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]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</RouterLink>
<CellValue v-else :column="column" :value="row[column.key]" />
<CellValue v-else :column="column" :value="row[column.key]" :row="row" />
</td>
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
<slot name="trailing" :row="row" />

View File

@@ -384,6 +384,77 @@
font-variant-numeric: tabular-nums;
}
/* A note under a stat value (Phase 18, UI-SPEC F7): placed inside
.summer-stat__value after the value text, because .summer-stat is
column-reverse. */
.summer-stat__note {
display: block;
margin-top: 4px;
font-size: 13px;
font-weight: 400;
line-height: 1.5;
color: var(--c-muted);
}
/* Table kit (Phase 18, UI-SPEC F7): a native-looking table inside a
partial card. The wrapper scrolls horizontally inside the card. */
.summer-table-wrap {
overflow-x: auto;
max-width: 100%;
}
.summer-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
}
.summer-table th {
height: var(--spacing-row-head);
padding: 0 14px;
font-size: 12px;
font-weight: 600;
color: var(--c-muted);
text-align: start;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
}
.summer-table td {
padding: 12px 14px;
vertical-align: top;
border-bottom: 1px solid var(--c-border);
overflow-wrap: anywhere;
}
.summer-table :where(th, td):first-child {
padding-inline-start: 0;
}
.summer-table :where(th, td):last-child {
padding-inline-end: 0;
}
.summer-table__num {
text-align: end;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.summer-table__sub {
display: block;
font-size: 13px;
color: var(--c-muted);
}
.summer-table tfoot td {
padding-top: 16px;
border-bottom: 0;
font-weight: 600;
}
/* Callout (Phase 12.1, UI-SPEC): a status hint above a preview screen. */
.summer-callout {
display: flex;

View File

@@ -13,7 +13,7 @@ import { FIELD_SCOPE, FORM_SESSION, createUploadGate } from '../components/form/
import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
import { RELATION_MANAGER } from '../components/form/registry'
import { RELATION_MANAGER, isRegisteredField } from '../components/form/registry'
import {
DEFAULT_TAB,
contextAllows,
@@ -70,6 +70,13 @@ const fields = computed<FormField[]>(() =>
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
)
// The Edit button renders only when the form holds at least one value field
// the SPA can edit whose context allows update (UI-SPEC F6): a record whose
// form shows only partials has nothing to edit and no primary button.
const hasEditableField = computed(() =>
(schema.value?.fields ?? []).some((field) => isRegisteredField(field) && contextAllows(field, 'update')),
)
// The same tabs as the form; a tab with no preview-visible field never
// appears, because tabs are built from the visible fields only.
const tabs = computed<TabItem[]>(() => {
@@ -290,7 +297,7 @@ void load()
@gone="onGone"
/>
<Button
v-if="!failed"
v-if="!failed && hasEditableField"
variant="primary"
data-action="edit"
:icon="Pencil"

View File

@@ -294,3 +294,39 @@ describe('preview view footer (UI-SPEC S2, D-10)', () => {
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
})
})
describe('preview edit rule (UI-SPEC F6)', () => {
it('shows no Edit button and no primary fill when the form holds only partial fields', async () => {
const partialOnly = schemaWith((schema) => {
schema.fields = [
{ name: 'cart', type: 'partial', label: 'Cart', path: 'cart' },
{ name: 'payments', type: 'partial', label: 'Payments', path: 'payments' },
] as typeof schema.fields
})
const { wrapper } = await mountApp(
PREVIEW,
routes({
[`GET ${LIST}/schema/form`]: partialOnly,
[`GET ${LIST}/partials/cart`]: { body: noHint },
[`GET ${LIST}/partials/payments`]: { body: noHint },
[`GET ${RECORD}`]: recordWith((record) => (record.meta.actions = [{ name: 'activate', label: 'Activate' }])),
}),
)
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
expect(wrapper.findAll('[data-preview] footer .bg-primary')).toHaveLength(0)
expect(footerButtons(wrapper)).toEqual(['activate'])
})
it('keeps the Edit button when the form has a text field the update context allows', async () => {
const { wrapper } = await mountApp(PREVIEW, routes())
expect(wrapper.find('[data-action="edit"]').exists()).toBe(true)
})
it('hides the Edit button when every value field is preview-only', async () => {
const previewOnly = schemaWith((schema) => {
schema.fields = [{ name: 'joined_ip', type: 'text', label: 'Joined from', context: 'preview' }] as typeof schema.fields
})
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: previewOnly }))
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
})
})

View File

@@ -14,6 +14,7 @@ const actions = [
{ name: 'reinstate', label: 'Reinstate', confirm: 'Lift the ban on this person?' },
{ name: 'activate', label: 'Activate' },
{ name: 'markup', label: '<b>Bold</b>' },
{ name: 'ban', label: 'Ban', confirm: 'Ban this person? This cannot be undone.', danger: true },
]
const strings = {
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
@@ -60,7 +61,7 @@ describe('record actions component (UI-SPEC S2, D-10)', () => {
it('renders the offered actions in the given order, labels as text', () => {
const { wrapper } = mountActions({})
const buttons = wrapper.findAll('[data-record-action]')
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup'])
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup', 'ban'])
expect(buttons[2]!.text()).toBe('<b>Bold</b>')
expect(buttons[2]!.find('b').exists()).toBe(false)
expect(buttons.every((item) => item.classes().includes('border-border-strong'))).toBe(true)
@@ -184,3 +185,25 @@ describe('record actions component (UI-SPEC S2, D-10)', () => {
expect(calls).toHaveLength(0)
})
})
describe('danger record actions (UI-SPEC F5)', () => {
it('gives the dialog confirm button the danger variant for a danger action, the default otherwise', async () => {
const { wrapper } = mountActions({})
await button(wrapper, 'ban').trigger('click')
await flushPromises()
const confirmButton = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
expect(confirmButton.className).toContain('bg-danger')
expect(confirmButton.className).not.toContain('bg-primary')
expect(confirmButton.textContent?.trim()).toBe('Ban')
// The footer button itself stays outline.
expect(button(wrapper, 'ban').classes()).toContain('border-border-strong')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
await button(wrapper, 'activate').trigger('click')
await flushPromises()
const plain = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
expect(plain.className).toContain('bg-primary')
expect(plain.className).not.toContain('bg-danger')
})
})

View File

@@ -1,14 +1,24 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import CellValue from '../../src/components/list/CellValue.vue'
import DataTable from '../../src/components/list/DataTable.vue'
import type { ListColumn } from '../../src/api/types'
import { listColumn } from '../fixtures/typed'
import { resetState } from '../helpers'
import { langFixture, listColumn } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
function cell(column: ListColumn, value: unknown) {
return mount({ components: { CellValue }, props: [], template: '<span data-cell><CellValue :column="column" :value="value" /></span>', data: () => ({ column, value }) })
function cell(column: ListColumn, value: unknown, row: Record<string, unknown> = {}) {
return mount({
components: { CellValue },
props: [],
template: '<span data-cell><CellValue :column="column" :value="value" :row="row" /></span>',
data: () => ({ column, value, row }),
})
}
const moneyFrom: ListColumn = { ...listColumn('name'), key: 'total', type: 'money', currencyFrom: 'currency', sortable: false }
const moneyEur: ListColumn = { ...listColumn('name'), key: 'fee', type: 'money', currency: 'EUR', sortable: false }
const text = listColumn('name')
const datetime = listColumn('created_at')
const toggle = listColumn('active')
@@ -108,3 +118,51 @@ describe('date and time cells (Phase 12.2)', () => {
}
})
})
describe('money column (UI-SPEC F1, D-07)', () => {
it("formats the decimal string with the row's currencyFrom code in en and pl", () => {
setBundle(langFixture.data, 'en')
const en = cell(moneyFrom, '99999999.9999', { currency: 'PLN' })
expect(en.find('[data-money]').text()).toBe('PLN\u00a099,999,999.9999')
expect(en.find('[data-money]').classes()).toEqual(expect.arrayContaining(['tabular-nums', 'whitespace-nowrap']))
setBundle(langFixture.data, 'pl')
const pl = cell(moneyFrom, '39.8400', { currency: 'PLN' })
expect(pl.find('[data-money]').text().replace(/\s/g, ' ')).toBe('39,84 PLN')
})
it('uses the literal currency of the column', () => {
setBundle(langFixture.data, 'en')
expect(cell(moneyEur, '1.5000').find('[data-money]').text()).toBe('EUR\u00a01.50')
})
it('shows the amount alone when the row has no currency, and the dash when empty', () => {
setBundle(langFixture.data, 'en')
expect(cell(moneyFrom, '12.5', { currency: '' }).find('[data-money]').text()).toBe('12.50')
expect(cell(moneyFrom, '12.5', {}).find('[data-money]').text()).toBe('12.50')
expect(cell(moneyFrom, null, { currency: 'PLN' }).find('[data-empty]').text()).toBe('—')
expect(cell(moneyFrom, '', { currency: 'PLN' }).find('[data-empty]').text()).toBe('—')
})
it('aligns the header and the cells of a money column to the right', async () => {
setBundle(langFixture.data, 'en')
signIn()
const router = await routerAt('/acme/demo/widgets')
const wrapper = mount(DataTable, {
props: {
columns: [listColumn('name'), moneyFrom],
rows: [
{ id: 1, name: 'One', total: '85.4193', currency: 'PLN' },
{ id: 2, name: 'Two', total: null, currency: 'PLN' },
],
},
global: { plugins: [router] },
})
const headers = wrapper.findAll('th')
expect(headers[1]!.classes()).toContain('text-right')
expect(headers[0]!.classes()).not.toContain('text-right')
const cells = wrapper.findAll('tbody tr')[0]!.findAll('td')
expect(cells[1]!.classes()).toContain('text-right')
expect(cells[1]!.find('[data-money]').text()).toBe('PLN\u00a085.4193')
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[1]!.find('[data-empty]').text()).toBe('—')
})
})