- type: money on fields.yaml with currency or currencyFrom, scale (2..4, default 4) and max; boot fails naming the field on two sources, none, a bad scale or max, a column that is not a string or a Scanner/Valuer, or a currencyFrom that is not a string column - a save answers 422 money.invalid, money.too_precise or money.too_large on the field and fills the accepted decimal string unchanged; the same check runs on controller, settings and relation child saves - backend::lang money.* phrases in en and pl - admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over Intl with the string itself), MoneyField.vue with the currency suffix, blur validation and the read-only box, preview through the control, the field scope on PreviewView; regenerated OpenAPI and TS types, rebuilt modules/boardwalk/dist - acme.payments fixture with TestMoneyFieldCompile and the Docker TestMoneyFieldSmoke; forms.md Money section and cabana README
193 lines
8.9 KiB
TypeScript
193 lines
8.9 KiB
TypeScript
// Money control (UI-SPEC F1, D-07 with the amended D-09 and D-10), the
|
||
// component on its own and the money helpers. No application names appear
|
||
// in framework tests.
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
|
||
import { computed, nextTick, ref } from 'vue'
|
||
import type { FormField } from '../../src/api/types'
|
||
import { setBundle } from '../../src/app/i18n'
|
||
import { formatMoney, parseMoneyInput, trimMoneyDisplay } from '../../src/app/money'
|
||
import MoneyField from '../../src/components/form/fields/MoneyField.vue'
|
||
import { FIELD_SCOPE, type FieldScope } from '../../src/components/form/formContext'
|
||
import { langFixture } from '../fixtures/typed'
|
||
import { resetState } from '../helpers'
|
||
|
||
const strings = {
|
||
'backend::lang.money.invalid': { other: 'This is not an amount.' },
|
||
'backend::lang.money.too_precise': { other: 'Use at most :scale decimal places.' },
|
||
'backend::lang.money.currency': { other: 'Currency: :code' },
|
||
}
|
||
|
||
function moneyField(extra: Partial<FormField> = {}): FormField {
|
||
return { name: 'total', type: 'money', label: 'Total', currency: 'PLN', scale: 4, ...extra } as FormField
|
||
}
|
||
|
||
function mountField(props: Record<string, unknown> = {}, values: Record<string, unknown> = {}) {
|
||
const field = { ...moneyField(), ...((props.field as Partial<FormField>) ?? {}) } as FormField
|
||
const scopeValues = ref(values)
|
||
const scope: FieldScope = {
|
||
fields: computed(() => [field]),
|
||
values: computed(() => scopeValues.value),
|
||
path: (name: string) => name,
|
||
}
|
||
const wrapper = mount(MoneyField, {
|
||
props: { modelValue: null, controlId: 'f-total', ...props, field },
|
||
global: { provide: { [FIELD_SCOPE as symbol]: scope } },
|
||
attachTo: document.body,
|
||
})
|
||
return { wrapper, scopeValues }
|
||
}
|
||
|
||
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
|
||
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
|
||
|
||
async function type(wrapper: VueWrapper, text: string): Promise<void> {
|
||
input(wrapper).element.value = text
|
||
await input(wrapper).trigger('input')
|
||
}
|
||
|
||
beforeEach(() => {
|
||
resetState()
|
||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||
})
|
||
|
||
afterEach(() => {
|
||
document.body.innerHTML = ''
|
||
})
|
||
|
||
enableAutoUnmount(afterEach)
|
||
|
||
describe('money helpers', () => {
|
||
it('parses typed text into the canonical dot string without a number', () => {
|
||
expect(parseMoneyInput('1 234,56', 4)).toEqual({ value: '1234.56', error: null })
|
||
expect(parseMoneyInput('1 234.5', 4)).toEqual({ value: '1234.5', error: null })
|
||
expect(parseMoneyInput('-12', 2)).toEqual({ value: '-12', error: null })
|
||
expect(parseMoneyInput('', 4)).toEqual({ value: null, error: null })
|
||
expect(parseMoneyInput(' ', 4)).toEqual({ value: null, error: null })
|
||
expect(parseMoneyInput('12.34567', 4)).toEqual({ value: null, error: 'too_precise' })
|
||
expect(parseMoneyInput('1.234,56', 4)).toEqual({ value: null, error: 'invalid' })
|
||
expect(parseMoneyInput('12a', 4)).toEqual({ value: null, error: 'invalid' })
|
||
expect(parseMoneyInput('1e3', 4)).toEqual({ value: null, error: 'invalid' })
|
||
expect(parseMoneyInput('.5', 4)).toEqual({ value: null, error: 'invalid' })
|
||
// A 20-digit amount survives: no float is involved.
|
||
expect(parseMoneyInput('12345678901234567890.1234', 4)).toEqual({ value: '12345678901234567890.1234', error: null })
|
||
})
|
||
|
||
it('trims trailing zeros down to two decimals for display', () => {
|
||
expect(trimMoneyDisplay('39.8400')).toBe('39.84')
|
||
expect(trimMoneyDisplay('85.4193')).toBe('85.4193')
|
||
expect(trimMoneyDisplay('5.0000')).toBe('5.00')
|
||
expect(trimMoneyDisplay('5.0100')).toBe('5.01')
|
||
expect(trimMoneyDisplay('7')).toBe('7')
|
||
expect(trimMoneyDisplay('7.5')).toBe('7.5')
|
||
})
|
||
|
||
it('formats through Intl with the string itself, keeping four decimals', () => {
|
||
expect(formatMoney('99999999.9999', 'PLN', 'en', 4)).toBe('PLN 99,999,999.9999')
|
||
expect(formatMoney('99999999.9999', 'PLN', 'pl', 4).replace(/\s/g, ' ')).toBe('99 999 999,9999 PLN')
|
||
expect(formatMoney('39.8400', 'PLN', 'en', 4)).toBe('PLN 39.84')
|
||
expect(formatMoney('39.8400', 'EUR', 'pl', 2).replace(/\s/g, ' ')).toBe('39,84 EUR')
|
||
expect(formatMoney('12.5', '', 'en', 4)).toBe('12.50')
|
||
})
|
||
})
|
||
|
||
describe('money field component (UI-SPEC F1)', () => {
|
||
it('is a text input in decimal mode with the currency suffix', () => {
|
||
const { wrapper } = mountField()
|
||
const el = input(wrapper)
|
||
expect(el.attributes('type')).toBe('text')
|
||
expect(el.attributes('inputmode')).toBe('decimal')
|
||
expect(el.attributes('autocomplete')).toBe('off')
|
||
expect(el.attributes('spellcheck')).toBe('false')
|
||
expect(el.classes()).toContain('pr-16')
|
||
expect(el.classes()).toContain('text-right')
|
||
expect(el.classes()).toContain('tabular-nums')
|
||
const suffix = wrapper.find('[data-money-currency]')
|
||
expect(suffix.text()).toBe('PLN')
|
||
expect(suffix.attributes('aria-hidden')).toBe('true')
|
||
expect(wrapper.find('#f-total-currency').text()).toBe('Currency: PLN')
|
||
expect(el.attributes('aria-describedby')).toContain('f-total-currency')
|
||
})
|
||
|
||
it('shows a loaded value trimmed, with the locale separator, and does not emit', async () => {
|
||
const { wrapper } = mountField({ modelValue: '39.8400' })
|
||
expect(input(wrapper).element.value).toBe('39.84')
|
||
expect(emitted(wrapper)).toEqual([])
|
||
setBundle({ ...langFixture.data, ...strings }, 'pl')
|
||
const { wrapper: polish } = mountField({ modelValue: '85.4193' })
|
||
expect(input(polish).element.value).toBe('85,4193')
|
||
expect(emitted(polish)).toEqual([])
|
||
})
|
||
|
||
it('submits an untouched 85.4193 byte-identical: no emit on load or blur', async () => {
|
||
const { wrapper } = mountField({ modelValue: '85.4193' })
|
||
await input(wrapper).trigger('focus')
|
||
await input(wrapper).trigger('blur')
|
||
expect(emitted(wrapper)).toEqual([])
|
||
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
|
||
})
|
||
|
||
it('emits the canonical dot string for "1 234,56" and null for empty', async () => {
|
||
const { wrapper } = mountField({ modelValue: '1.00' })
|
||
await type(wrapper, '1 234,56')
|
||
expect(emitted(wrapper)).toEqual(['1234.56'])
|
||
await type(wrapper, '')
|
||
expect(emitted(wrapper)).toEqual(['1234.56', null])
|
||
})
|
||
|
||
it('refuses 12.34567 at scale 4 on blur, neither rounded nor truncated', async () => {
|
||
const { wrapper } = mountField({ modelValue: null })
|
||
await type(wrapper, '12.34567')
|
||
// Nothing is shown before blur, and no rounded value was emitted.
|
||
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
|
||
expect(emitted(wrapper)).toEqual(['12.34567'])
|
||
await input(wrapper).trigger('blur')
|
||
const line = wrapper.find('[data-money-error]')
|
||
expect(line.text()).toBe('Use at most 4 decimal places.')
|
||
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
|
||
expect(input(wrapper).classes()).toContain('border-danger')
|
||
expect(input(wrapper).attributes('aria-describedby')).toContain('f-total-money-error')
|
||
expect(emitted(wrapper)).toEqual(['12.34567'])
|
||
// Fixing the text clears the line while typing.
|
||
await type(wrapper, '12.3456')
|
||
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
|
||
expect(emitted(wrapper)).toEqual(['12.34567', '12.3456'])
|
||
})
|
||
|
||
it('shows money.invalid for a second separator', async () => {
|
||
const { wrapper } = mountField()
|
||
await type(wrapper, '1.234,56')
|
||
await input(wrapper).trigger('blur')
|
||
expect(wrapper.find('[data-money-error]').text()).toBe('This is not an amount.')
|
||
})
|
||
|
||
it('follows the currencyFrom sibling and omits the suffix without a code', async () => {
|
||
const { wrapper, scopeValues } = mountField({ field: { currency: undefined, currencyFrom: 'currency' } }, { currency: '' })
|
||
expect(wrapper.find('[data-money-currency]').exists()).toBe(false)
|
||
expect(input(wrapper).classes()).toContain('pr-3.5')
|
||
expect(input(wrapper).attributes('aria-describedby')).toBeUndefined()
|
||
scopeValues.value = { currency: 'EUR' }
|
||
await nextTick()
|
||
expect(wrapper.find('[data-money-currency]').text()).toBe('EUR')
|
||
expect(input(wrapper).classes()).toContain('pr-16')
|
||
})
|
||
|
||
it('renders a read-only box with the formatted amount or an em dash', () => {
|
||
const { wrapper } = mountField({ modelValue: '39.8400', field: { readOnly: true } })
|
||
const box = wrapper.find('[data-money-readonly]')
|
||
expect(box.exists()).toBe(true)
|
||
expect(box.text()).toBe('PLN 39.84')
|
||
expect(box.classes()).toContain('tabular-nums')
|
||
expect(wrapper.find('input').exists()).toBe(false)
|
||
const { wrapper: empty } = mountField({ modelValue: null, field: { attributes: { readonly: true } } })
|
||
expect(empty.find('[data-money-readonly] [data-empty]').text()).toBe('—')
|
||
})
|
||
|
||
it('replaces the draft when the value is set from outside', async () => {
|
||
const { wrapper } = mountField({ modelValue: '1.00' })
|
||
await type(wrapper, '2')
|
||
await wrapper.setProps({ modelValue: '5.0000' })
|
||
expect(input(wrapper).element.value).toBe('5.00')
|
||
})
|
||
})
|