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:
@@ -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"
|
||||
},
|
||||
|
||||
13
admin/src/api/schema.d.ts
vendored
13
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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