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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('—')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user