Files
summercms/admin/tests/list/CellValue.test.ts
Jakub Zych 0c8605123e 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
2026-10-13 11:22:36 +02:00

169 lines
6.8 KiB
TypeScript

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 { langFixture, listColumn } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
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')
beforeEach(() => {
resetState()
})
describe('plugin field type columns (16.2)', () => {
it('renders the ListValue text of a plugin type as plain text', () => {
const wrapper = cell({ ...text, type: 'notes' }, '<b>3 blocks</b>')
expect(wrapper.text()).toBe('<b>3 blocks</b>')
expect(wrapper.find('b').exists()).toBe(false)
})
})
describe('cell value (D-12)', () => {
it.each([
['a string', 'Blue', 'Blue'],
['a number', 12, '12'],
['an array', ['small', 3, null, ''], 'small, 3'],
['a boolean true', true, 'Tak'],
['a boolean false', false, 'Nie'],
])('renders %s as plain text', (_label, value, expected) => {
const wrapper = cell(text, value)
expect(wrapper.text()).toBe(expected)
expect(wrapper.find('[data-empty]').exists()).toBe(false)
})
it.each([
['null', null],
['undefined', undefined],
['an empty string', ''],
['an object', { nested: true }],
['an empty array', []],
])('renders %s as a muted dash', (_label, value) => {
const wrapper = cell(text, value)
expect(wrapper.find('[data-empty]').text()).toBe('—')
})
it('never renders markup from a value', () => {
const wrapper = cell(text, '<img src=x onerror=alert(1)>')
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toBe('<img src=x onerror=alert(1)>')
})
it('formats datetimes as YYYY-MM-DD HH:mm in tabular numbers', () => {
const wrapper = cell(datetime, '2026-09-21T14:32:00')
expect(wrapper.text()).toBe('2026-09-21 14:32')
expect(wrapper.find('span.tabular-nums').exists()).toBe(true)
expect(cell(datetime, 'not a date').text()).toBe('not a date')
expect(cell(datetime, null).find('[data-empty]').exists()).toBe(true)
expect(cell(datetime, true).find('[data-empty]').exists()).toBe(true)
})
it.each([
[true, 'true', 'Tak'],
[1, 'true', 'Tak'],
['yes', 'true', 'Tak'],
[false, 'false', 'Nie'],
[0, 'false', 'Nie'],
['0', 'false', 'Nie'],
['FALSE', 'false', 'Nie'],
])('renders the switch value %j as the %s pill', (value, state, label) => {
const wrapper = cell(toggle, value)
expect(wrapper.find('[data-switch]').attributes('data-switch')).toBe(state)
expect(wrapper.text()).toBe(label)
})
it('renders an empty switch value as a dash', () => {
expect(cell(toggle, null).find('[data-empty]').exists()).toBe(true)
expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false)
})
})
describe('date and time cells (Phase 12.2)', () => {
const date = { ...listColumn('created_at'), key: 'released_on', type: 'date' } as ListColumn
const time = { ...listColumn('created_at'), key: 'opens_at', type: 'time' } as ListColumn
it('shows a stored date as is, never shifted by a time zone', () => {
expect(cell(date, '2026-10-02').text()).toBe('2026-10-02')
expect(cell(date, '2026-01-01T00:00:00Z').text()).toBe('2026-01-01')
expect(cell(date, '2026-10-02').find('span.tabular-nums').exists()).toBe(true)
})
it('shows a stored time without its seconds', () => {
expect(cell(time, '14:30:15').text()).toBe('14:30')
expect(cell(time, '08:05').text()).toBe('08:05')
expect(cell(time, '14:30:15').find('span.tabular-nums').exists()).toBe(true)
})
it('renders an empty date or time as a muted dash', () => {
for (const column of [date, time]) {
for (const value of [null, undefined, '']) {
expect(cell(column, value).find('[data-empty]').text()).toBe('—')
}
}
})
})
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('—')
})
})