feat(18-01): add the cabana money field (task 1)
- 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
This commit is contained in:
@@ -1310,6 +1310,14 @@
|
||||
"context": {
|
||||
"$ref": "#/components/schemas/cabana.fieldContext"
|
||||
},
|
||||
"currency": {
|
||||
"description": "Currency is the ISO 4217 code a `type: money` field shows as its\nsuffix; exactly one of Currency and CurrencyFrom is set.",
|
||||
"type": "string"
|
||||
},
|
||||
"currencyFrom": {
|
||||
"description": "CurrencyFrom names the sibling column of the model whose value is the\nmoney field's currency code; the admin follows that field's value.",
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/schemas/cabana.jsonScalar"
|
||||
},
|
||||
@@ -1375,6 +1383,10 @@
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"max": {
|
||||
"description": "Max is a money field's column ceiling as a decimal string; a value\nabove it in absolute magnitude is a 422 money.too_large.",
|
||||
"type": "string"
|
||||
},
|
||||
"maxDate": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1459,6 +1471,10 @@
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"scale": {
|
||||
"description": "Scale is the most fraction digits a money value may carry, 2 to 4\n(default 4). A value with more digits is refused, never rounded.",
|
||||
"type": "integer"
|
||||
},
|
||||
"size": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
20
admin/src/api/schema.d.ts
vendored
20
admin/src/api/schema.d.ts
vendored
@@ -5592,6 +5592,16 @@ export interface components {
|
||||
};
|
||||
comment?: string;
|
||||
context?: components["schemas"]["cabana.fieldContext"];
|
||||
/**
|
||||
* @description Currency is the ISO 4217 code a `type: money` field shows as its
|
||||
* suffix; exactly one of Currency and CurrencyFrom is set.
|
||||
*/
|
||||
currency?: string;
|
||||
/**
|
||||
* @description CurrencyFrom names the sibling column of the model whose value is the
|
||||
* money field's currency code; the admin follows that field's value.
|
||||
*/
|
||||
currencyFrom?: string;
|
||||
default?: components["schemas"]["cabana.jsonScalar"];
|
||||
/**
|
||||
* @description Deferrable is true on a relation-manager field whose relation can be
|
||||
@@ -5634,6 +5644,11 @@ export interface components {
|
||||
/** @description ImageWidth and ImageHeight are the preview size of an image upload. */
|
||||
imageWidth?: number;
|
||||
label?: string;
|
||||
/**
|
||||
* @description Max is a money field's column ceiling as a decimal string; a value
|
||||
* above it in absolute magnitude is a 422 money.too_large.
|
||||
*/
|
||||
max?: string;
|
||||
maxDate?: string;
|
||||
/** @description MaxFiles caps the number of files of an attachMany field. */
|
||||
maxFiles?: number;
|
||||
@@ -5690,6 +5705,11 @@ export interface components {
|
||||
readOnly?: boolean;
|
||||
relation?: string;
|
||||
required?: boolean;
|
||||
/**
|
||||
* @description Scale is the most fraction digits a money value may carry, 2 to 4
|
||||
* (default 4). A value with more digits is refused, never rounded.
|
||||
*/
|
||||
scale?: number;
|
||||
size?: string;
|
||||
span?: string;
|
||||
tab?: string;
|
||||
|
||||
98
admin/src/app/money.ts
Normal file
98
admin/src/app/money.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Exact-decimal money helpers of the `money` field and list column (Phase 18
|
||||
// D-07, amended D-09, D-10). A money value is the decimal string the server
|
||||
// stores ("39.8400", "85.4193"); it is never turned into a JS number here:
|
||||
// parsing is text work on digits and separators, and formatting hands the
|
||||
// string itself to Intl.NumberFormat, which accepts decimal strings without
|
||||
// going through a float.
|
||||
|
||||
export type MoneyParseError = 'invalid' | 'too_precise'
|
||||
|
||||
export interface MoneyParseResult {
|
||||
/** The canonical dot-separated string, or null for empty text. */
|
||||
value: string | null
|
||||
/** Why the text was refused, or null when value holds the result. */
|
||||
error: MoneyParseError | null
|
||||
}
|
||||
|
||||
const digitsOnly = /^[0-9]+$/
|
||||
|
||||
/**
|
||||
* Turns typed text into the canonical money string: spaces and U+00A0 are
|
||||
* stripped, one comma or dot is the decimal separator, an optional leading
|
||||
* minus is kept, digits only otherwise. Empty text is null. More than
|
||||
* `scale` fraction digits is `too_precise`: the value is never rounded or
|
||||
* truncated. Anything else (letters, two separators, grouping) is `invalid`.
|
||||
*/
|
||||
export function parseMoneyInput(text: string, scale: number): MoneyParseResult {
|
||||
const compact = text.replace(/[\s ]/g, '')
|
||||
if (compact === '') {
|
||||
return { value: null, error: null }
|
||||
}
|
||||
let body = compact
|
||||
let negative = false
|
||||
if (body.startsWith('-')) {
|
||||
negative = true
|
||||
body = body.slice(1)
|
||||
}
|
||||
const separators = body.match(/[.,]/g) ?? []
|
||||
if (separators.length > 1) {
|
||||
return { value: null, error: 'invalid' }
|
||||
}
|
||||
const [whole, fraction = ''] = body.split(/[.,]/)
|
||||
if (!digitsOnly.test(whole ?? '') || (separators.length === 1 && !digitsOnly.test(fraction))) {
|
||||
return { value: null, error: 'invalid' }
|
||||
}
|
||||
if (fraction.length > scale) {
|
||||
return { value: null, error: 'too_precise' }
|
||||
}
|
||||
const canonical = fraction === '' ? whole : `${whole}.${fraction}`
|
||||
return { value: `${negative ? '-' : ''}${canonical}`, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored string for display in the input: trailing fraction zeros are
|
||||
* trimmed down to two decimals ("39.8400" → "39.84", "85.4193" unchanged,
|
||||
* "5.0000" → "5.00", "7" → "7"). The separator stays a dot; the field swaps
|
||||
* it for the locale's own.
|
||||
*/
|
||||
export function trimMoneyDisplay(stored: string): string {
|
||||
const [whole, fraction] = stored.split('.')
|
||||
if (fraction === undefined || fraction.length <= 2) {
|
||||
return stored
|
||||
}
|
||||
let end = fraction.length
|
||||
while (end > 2 && fraction.charAt(end - 1) === '0') {
|
||||
end -= 1
|
||||
}
|
||||
return `${whole}.${fraction.slice(0, end)}`
|
||||
}
|
||||
|
||||
/** The decimal separator Intl uses for a locale ("," for pl, "." for en). */
|
||||
export function decimalSeparator(locale: string): string {
|
||||
const parts = new Intl.NumberFormat(locale).formatToParts(1.1)
|
||||
return parts.find((part) => part.type === 'decimal')?.value ?? '.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a money string for reading: Intl.NumberFormat with the ISO code
|
||||
* (`currencyDisplay: 'code'`), at least two and at most `scale` fraction
|
||||
* digits, so "99999999.9999" keeps all four decimals and "39.8400" shows two.
|
||||
* Without a currency the amount alone is formatted (`style: 'decimal'`). The
|
||||
* string is passed to format() as is; it is never converted to a number.
|
||||
*/
|
||||
export function formatMoney(value: string, currency: string, locale: string, scale = 4): string {
|
||||
const digits = { minimumFractionDigits: 2, maximumFractionDigits: Math.max(2, scale) }
|
||||
const options: Intl.NumberFormatOptions =
|
||||
currency === '' ? { style: 'decimal', ...digits } : { style: 'currency', currency, currencyDisplay: 'code', ...digits }
|
||||
let formatter: Intl.NumberFormat
|
||||
try {
|
||||
formatter = new Intl.NumberFormat(locale, options)
|
||||
} catch {
|
||||
// An unknown ISO code: show the amount with the code appended.
|
||||
const plain = new Intl.NumberFormat(locale, { style: 'decimal', ...digits })
|
||||
return `${plain.format(value as unknown as number)} ${currency}`.trim()
|
||||
}
|
||||
// Intl accepts decimal strings (ECMA-402 "intl mathematical value"); the
|
||||
// cast only satisfies the lib typings that predate it.
|
||||
return formatter.format(value as unknown as number)
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { isPluginField, rendererForField } from './registry'
|
||||
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
|
||||
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
|
||||
// contrast and are read in order. Controls that already have a read-only
|
||||
// mode (datepicker, fileupload, permissioneditor), partials and plugin field
|
||||
// types (their element gets `disabled`) are rendered through their own
|
||||
// mode (datepicker, fileupload, permissioneditor, money), partials and plugin
|
||||
// field types (their element gets `disabled`) are rendered through their own
|
||||
// component with the field marked read-only.
|
||||
const props = defineProps<{
|
||||
field: FormField
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
/** Types rendered by their own control in its read-only mode. */
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater'])
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money'])
|
||||
|
||||
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
||||
const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
157
admin/src/components/form/fields/MoneyField.vue
Normal file
157
admin/src/components/form/fields/MoneyField.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { currentLocale, t } from '../../../app/i18n'
|
||||
import { decimalSeparator, formatMoney, parseMoneyInput, trimMoneyDisplay, type MoneyParseError } from '../../../app/money'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||
import { FIELD_SCOPE } from '../formContext'
|
||||
|
||||
// Exact-decimal amount control for `type: money` (UI-SPEC F1, D-07 with the
|
||||
// amended D-09 and D-10). The value is the server's decimal string or null;
|
||||
// it is never parsed into a number. A loaded value is shown with its
|
||||
// trailing zeros trimmed to two decimals and the locale's separator, and is
|
||||
// sent back byte-identical until the administrator edits it. Typing emits
|
||||
// the canonical dot string on every valid input and null on empty; a
|
||||
// malformed or too-precise text is emitted as typed (never rounded or
|
||||
// truncated), so the server's 422 stays the gate, and the error line shows
|
||||
// on blur. The currency suffix is the field's literal code or the live value
|
||||
// of the currencyFrom sibling; without a code the suffix is omitted.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
|
||||
|
||||
const scope = inject(FIELD_SCOPE, null)
|
||||
const scale = computed(() => props.field.scale ?? 4)
|
||||
const currency = computed(() => {
|
||||
if (props.field.currencyFrom) {
|
||||
const sibling = scope?.values.value[props.field.currencyFrom]
|
||||
return typeof sibling === 'string' ? sibling.trim() : ''
|
||||
}
|
||||
return props.field.currency ?? ''
|
||||
})
|
||||
const readOnly = computed(() => {
|
||||
const attribute = props.field.attributes?.readonly
|
||||
return !!props.field.readOnly || (attribute !== undefined && attribute !== null && attribute !== false)
|
||||
})
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
const locale = computed(() => currentLocale.value)
|
||||
const separator = computed(() => decimalSeparator(locale.value))
|
||||
const currencyId = computed(() => `${props.controlId}-currency`)
|
||||
const errorId = computed(() => `${props.controlId}-money-error`)
|
||||
|
||||
/** The stored string as the input shows it: trimmed, with the locale's separator. */
|
||||
function displayOf(value: unknown): string {
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
return ''
|
||||
}
|
||||
return trimMoneyDisplay(value).replace('.', separator.value)
|
||||
}
|
||||
|
||||
const text = ref(displayOf(props.modelValue))
|
||||
const error = ref<MoneyParseError | null>(null)
|
||||
// Errors show on blur, and while typing once one has shown.
|
||||
const showErrors = ref(false)
|
||||
let lastEmitted: string | null | undefined
|
||||
|
||||
// A value set from outside (a load, a reset after save) replaces the draft;
|
||||
// the field's own emits do not.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value !== lastEmitted) {
|
||||
text.value = displayOf(value)
|
||||
error.value = null
|
||||
showErrors.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** What a draft emits: the canonical string, null, or the raw text when refused. */
|
||||
function emitted(draft: string): { value: string | null; error: MoneyParseError | null } {
|
||||
const result = parseMoneyInput(draft, scale.value)
|
||||
if (result.error === null) {
|
||||
return result
|
||||
}
|
||||
return { value: draft.replace(/[\s ]/g, ''), error: result.error }
|
||||
}
|
||||
|
||||
function onInput(event: Event): void {
|
||||
text.value = (event.target as HTMLInputElement).value
|
||||
const next = emitted(text.value)
|
||||
if (showErrors.value) {
|
||||
error.value = next.error
|
||||
}
|
||||
lastEmitted = next.value
|
||||
emit('update:modelValue', next.value)
|
||||
}
|
||||
|
||||
function onBlur(): void {
|
||||
showErrors.value = true
|
||||
error.value = emitted(text.value).error
|
||||
}
|
||||
|
||||
const invalid = computed(() => !!props.invalid || error.value !== null)
|
||||
const errorText = computed(() =>
|
||||
error.value === 'too_precise'
|
||||
? t('backend::lang.money.too_precise', { scale: scale.value })
|
||||
: t('backend::lang.money.invalid'),
|
||||
)
|
||||
const formatted = computed(() =>
|
||||
typeof props.modelValue === 'string' && props.modelValue !== ''
|
||||
? formatMoney(props.modelValue, currency.value, locale.value, scale.value)
|
||||
: '',
|
||||
)
|
||||
const describedBy = computed(() =>
|
||||
[props.describedBy ?? '', currency.value !== '' ? currencyId.value : '', error.value !== null ? errorId.value : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="readOnly"
|
||||
:id="controlId"
|
||||
data-money-readonly
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="flex h-input items-center justify-end rounded-control border border-border bg-subtle px-3.5 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
<span v-if="formatted !== ''">{{ formatted }}</span>
|
||||
<span v-else class="text-muted" data-empty>—</span>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-col gap-1.5">
|
||||
<div class="relative">
|
||||
<input
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), currency !== '' ? 'pr-16' : 'pr-3.5']"
|
||||
class="h-input text-right tabular-nums"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<span
|
||||
v-if="currency !== ''"
|
||||
data-money-currency
|
||||
aria-hidden="true"
|
||||
class="absolute inset-y-px right-px flex min-w-[52px] items-center justify-center rounded-r-control border-l border-border bg-subtle px-3 text-muted"
|
||||
>{{ currency }}</span
|
||||
>
|
||||
<span v-if="currency !== ''" :id="currencyId" class="sr-only">{{
|
||||
t('backend::lang.money.currency', { code: currency })
|
||||
}}</span>
|
||||
</div>
|
||||
<p v-if="error !== null" :id="errorId" data-money-error class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ errorText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,8 @@
|
||||
// a nested FormGrid, labelled as a group. It renders FormGrid, so it sits on
|
||||
// an import cycle with this module; only this module imports it, so this
|
||||
// module always evaluates first and the binding is complete here.
|
||||
// Phase 18 adds the money control (D-07): an exact decimal string value
|
||||
// with a currency suffix, formatted by locale only for reading.
|
||||
import type { Component } from 'vue'
|
||||
import type { FormField } from '../../api/types'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
@@ -48,6 +50,7 @@ import SwitchField from './fields/SwitchField.vue'
|
||||
import TextField from './fields/TextField.vue'
|
||||
import TextareaField from './fields/TextareaField.vue'
|
||||
import MarkdownField from './fields/MarkdownField.vue'
|
||||
import MoneyField from './fields/MoneyField.vue'
|
||||
import MLTextField from './fields/MLTextField.vue'
|
||||
import MLTextareaField from './fields/MLTextareaField.vue'
|
||||
import MLMarkdownField from './fields/MLMarkdownField.vue'
|
||||
@@ -89,6 +92,7 @@ const renderers = new Map<string, Component>([
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
['richeditor', RichEditorField],
|
||||
['repeater', RepeaterField],
|
||||
['money', MoneyField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
|
||||
@@ -9,7 +9,7 @@ import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { message, t } from '../app/i18n'
|
||||
import { loadControllerAssets } from '../app/pluginAssets'
|
||||
import { newSessionKey } from '../app/sessionKey'
|
||||
import { FORM_SESSION, createUploadGate } from '../components/form/formContext'
|
||||
import { FIELD_SCOPE, FORM_SESSION, createUploadGate } from '../components/form/formContext'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import PreviewField from '../components/form/PreviewField.vue'
|
||||
import RecordActions from '../components/form/RecordActions.vue'
|
||||
@@ -193,6 +193,13 @@ function onGone(): void {
|
||||
// never uploads or saves, so the session only carries the routes.
|
||||
const { activeUploads, beginUpload } = createUploadGate()
|
||||
const sessionKey = newSessionKey()
|
||||
// A money field reads its currencyFrom sibling through the field scope, as
|
||||
// on a form.
|
||||
provide(FIELD_SCOPE, {
|
||||
fields: computed(() => fields.value),
|
||||
values: computed(() => values.value),
|
||||
path: (name: string) => name,
|
||||
})
|
||||
provide(FORM_SESSION, {
|
||||
key: sessionKey,
|
||||
recordId,
|
||||
|
||||
192
admin/tests/form/MoneyField.test.ts
Normal file
192
admin/tests/form/MoneyField.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user