fix(input-amount): parse amount always on locale once the amount is formatted (#2439)

* fix(input-amount): parse amount always  on locale once the amount is formatted

* chore: add unit test

* chore: add some description

* chore: update playwright script to install dependencies

* Update .github/workflows/verify-pr.yml

* chore: set formatOptions temp and cleanup for programmatic api

* feat(form-core): add "user-edit" mode to formatOptions while editing existing value of a form control

* chore: enhance code readability and robustness

---------

Co-authored-by: Thijs Louisse <thijs.louisse@ing.com>
This commit is contained in:
gerjanvangeest 2025-01-15 09:59:02 +01:00 committed by GitHub
parent a992da4340
commit 35e66052b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 162 additions and 36 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': minor
---
[form-core] add "user-edit" mode to formatOptions while editing existing value of a form control

View file

@ -249,7 +249,7 @@ const FormatMixinImplementation = superclass =>
// Apparently, the parser was not able to produce a satisfactory output for the desired // Apparently, the parser was not able to produce a satisfactory output for the desired
// modelValue type, based on the current viewValue. Unparseable allows to restore all // modelValue type, based on the current viewValue. Unparseable allows to restore all
// states (for instance from a lost user session), since it saves the current viewValue. // states (for instance from a lost user session), since it saves the current viewValue.
const result = this.parser(value, this.formatOptions); const result = this.parser(value, { ...this.formatOptions, mode: this.#getFormatMode() });
return result !== undefined ? result : new Unparseable(value); return result !== undefined ? result : new Unparseable(value);
} }
@ -269,13 +269,8 @@ const FormatMixinImplementation = superclass =>
// imperatively, we DO want to format a value (it is the only way to get meaningful // imperatively, we DO want to format a value (it is the only way to get meaningful
// input into `._inputNode` with modelValue as input) // input into `._inputNode` with modelValue as input)
if ( if (this._isHandlingUserInput && this.hasFeedbackFor?.includes('error') && this._inputNode) {
this._isHandlingUserInput && return this.value;
this.hasFeedbackFor?.length &&
this.hasFeedbackFor.includes('error') &&
this._inputNode
) {
return this._inputNode ? this.value : undefined;
} }
if (this.modelValue instanceof Unparseable) { if (this.modelValue instanceof Unparseable) {
@ -285,7 +280,10 @@ const FormatMixinImplementation = superclass =>
return this.modelValue.viewValue; return this.modelValue.viewValue;
} }
return this.formatter(this.modelValue, this.formatOptions); return this.formatter(this.modelValue, {
...this.formatOptions,
mode: this.#getFormatMode(),
});
} }
/** /**
@ -348,7 +346,6 @@ const FormatMixinImplementation = superclass =>
* @private * @private
*/ */
__handlePreprocessor() { __handlePreprocessor() {
const unprocessedValue = this.value;
let currentCaretIndex = this.value.length; let currentCaretIndex = this.value.length;
// Be gentle with Safari // Be gentle with Safari
if ( if (
@ -364,7 +361,6 @@ const FormatMixinImplementation = superclass =>
prevViewValue: this.__prevViewValue, prevViewValue: this.__prevViewValue,
}); });
this.__prevViewValue = unprocessedValue;
if (preprocessedValue === undefined) { if (preprocessedValue === undefined) {
// Make sure we do no set back original value, so we preserve // Make sure we do no set back original value, so we preserve
// caret index (== selectionStart/selectionEnd) // caret index (== selectionStart/selectionEnd)
@ -459,7 +455,7 @@ const FormatMixinImplementation = superclass =>
/** /**
* Configuration object that will be available inside the formatter function * Configuration object that will be available inside the formatter function
*/ */
this.formatOptions = /** @type {FormatOptions} */ ({}); this.formatOptions = /** @type {FormatOptions} */ ({ mode: 'auto' });
/** /**
* The view value is the result of the formatter function (when available). * The view value is the result of the formatter function (when available).
@ -543,10 +539,8 @@ const FormatMixinImplementation = superclass =>
*/ */
__onPaste() { __onPaste() {
this._isPasting = true; this._isPasting = true;
this.formatOptions.mode = 'pasted'; queueMicrotask(() => {
setTimeout(() => {
this._isPasting = false; this._isPasting = false;
this.formatOptions.mode = 'auto';
}); });
} }
@ -588,6 +582,17 @@ const FormatMixinImplementation = superclass =>
this._inputNode.removeEventListener('compositionend', this.__onCompositionEvent); this._inputNode.removeEventListener('compositionend', this.__onCompositionEvent);
} }
} }
#getFormatMode() {
if (this._isPasting) {
return 'pasted';
}
const isUserEditing = this._isHandlingUserInput && this.__prevViewValue;
if (isUserEditing) {
return 'user-edit';
}
return 'auto';
}
}; };
export const FormatMixin = dedupeMixin(FormatMixinImplementation); export const FormatMixin = dedupeMixin(FormatMixinImplementation);

View file

@ -5,6 +5,8 @@ import sinon from 'sinon';
import { Unparseable, Validator, FormatMixin } from '@lion/ui/form-core.js'; import { Unparseable, Validator, FormatMixin } from '@lion/ui/form-core.js';
import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js'; import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
const isLionInputStepper = (/** @type {FormatClass} */ el) => 'valueTextMapping' in el;
/** /**
* @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost * @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
* @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType * @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
@ -480,7 +482,7 @@ export function runFormatMixinSuite(customConfig) {
/** /**
* @param {FormatClass} el * @param {FormatClass} el
*/ */
function paste(el, val = 'lorem') { function mimicPaste(el, val = 'lorem') {
const { _inputNode } = getFormControlMembers(el); const { _inputNode } = getFormControlMembers(el);
_inputNode.value = val; _inputNode.value = val;
_inputNode.dispatchEvent(new ClipboardEvent('paste', { bubbles: true })); _inputNode.dispatchEvent(new ClipboardEvent('paste', { bubbles: true }));
@ -494,12 +496,17 @@ export function runFormatMixinSuite(customConfig) {
`) `)
); );
const formatterSpy = sinon.spy(el, 'formatter'); const formatterSpy = sinon.spy(el, 'formatter');
paste(el); mimicPaste(el);
expect(formatterSpy).to.be.called; expect(formatterSpy).to.be.called;
expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('pasted'); expect(/** @type {{mode: string}} */ (formatterSpy.lastCall.args[1]).mode).to.equal(
'pasted',
);
// give microtask of _isPasting chance to reset
await aTimeout(0); await aTimeout(0);
mimicUserInput(el, ''); el.modelValue = 'foo';
expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('auto'); expect(/** @type {{mode: string}} */ (formatterSpy.lastCall.args[1]).mode).to.equal(
'auto',
);
}); });
it('sets protected value "_isPasting" for Subclassers', async () => { it('sets protected value "_isPasting" for Subclassers', async () => {
@ -509,7 +516,7 @@ export function runFormatMixinSuite(customConfig) {
`) `)
); );
const formatterSpy = sinon.spy(el, 'formatter'); const formatterSpy = sinon.spy(el, 'formatter');
paste(el); mimicPaste(el);
expect(formatterSpy).to.have.been.called; expect(formatterSpy).to.have.been.called;
// @ts-ignore [allow-protected] in test // @ts-ignore [allow-protected] in test
expect(el._isPasting).to.be.true; expect(el._isPasting).to.be.true;
@ -526,7 +533,7 @@ export function runFormatMixinSuite(customConfig) {
); );
// @ts-ignore [allow-protected] in test // @ts-ignore [allow-protected] in test
const reflectBackSpy = sinon.spy(el, '_reflectBackOn'); const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
paste(el); mimicPaste(el);
expect(reflectBackSpy).to.have.been.called; expect(reflectBackSpy).to.have.been.called;
}); });
@ -538,10 +545,33 @@ export function runFormatMixinSuite(customConfig) {
); );
// @ts-ignore [allow-protected] in test // @ts-ignore [allow-protected] in test
const reflectBackSpy = sinon.spy(el, '_reflectBackOn'); const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
paste(el); mimicPaste(el);
expect(reflectBackSpy).to.have.been.called; expect(reflectBackSpy).to.have.been.called;
}); });
}); });
describe('On user input', () => {
it('adjusts formatOptions.mode to "user-edit" for parser when user changes value', async () => {
const el = /** @type {FormatClass} */ (
await fixture(html`<${tag}><input slot="input"></${tag}>`)
);
const parserSpy = sinon.spy(el, 'parser');
// Here we get auto as we start from '' (so there was no value to edit)
mimicUserInput(el, 'some val');
expect(/** @type {{mode: string}} */ (parserSpy.lastCall.args[1]).mode).to.equal(
'auto',
);
await el.updateComplete;
mimicUserInput(el, 'some other val');
expect(/** @type {{mode: string}} */ (parserSpy.lastCall.args[1]).mode).to.equal(
'user-edit',
);
await el.updateComplete;
});
});
}); });
describe('Parser', () => { describe('Parser', () => {
@ -597,6 +627,43 @@ export function runFormatMixinSuite(customConfig) {
expect(_inputNode.value).to.equal(val); expect(_inputNode.value).to.equal(val);
}); });
it('does only calculate derived values as consequence of user input when preprocessed value is different from previous view value', async () => {
const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
if (typeof val !== 'string') return;
const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
const el = /** @type {FormatClass} */ (
await fixture(html`
<${tag} .preprocessor=${preprocessorSpy}>
<input slot="input" .value="${val}">
</${tag}>
`)
);
// TODO: find out why we need to skip this for lion-input-stepper
if (isLionInputStepper(el)) return;
/**
* The _calculateValues method is called inside _onUserInputChanged w/o providing args
* @param {sinon.SinonSpyCall} call
* @returns {boolean}
*/
const isCalculateCallAfterUserInput = call => call.args[0]?.length === 0;
const didRecalculateAfterUserInput = (/** @type {sinon.SinonSpy<any[], any>} */ spy) =>
spy.callCount > 1 && !spy.getCalls().find(isCalculateCallAfterUserInput);
// @ts-expect-error [allow-protected] in test
const calcValuesSpy = sinon.spy(el, '_calculateValues');
// this value gets preprocessed to 'val'
mimicUserInput(el, `${val}$`);
expect(didRecalculateAfterUserInput(calcValuesSpy)).to.be.false;
// this value gets preprocessed to 'value' (and thus differs from previous)
mimicUserInput(el, `${val}ue$`);
expect(didRecalculateAfterUserInput(calcValuesSpy)).to.be.true;
});
it('does not preprocess during composition', async () => { it('does not preprocess during composition', async () => {
const el = /** @type {FormatClass} */ ( const el = /** @type {FormatClass} */ (
await fixture(html` await fixture(html`

View file

@ -3,7 +3,7 @@ import { LitElement } from 'lit';
import { ValidateHost } from './validate/ValidateMixinTypes.js'; import { ValidateHost } from './validate/ValidateMixinTypes.js';
import { FormControlHost } from './FormControlMixinTypes.js'; import { FormControlHost } from './FormControlMixinTypes.js';
export type FormatOptions = { mode: 'pasted' | 'auto' } & object; export type FormatOptions = { mode: 'pasted' | 'auto' | 'user-edit'} & object;
export declare class FormatHost { export declare class FormatHost {
/** /**
* Converts viewValue to modelValue * Converts viewValue to modelValue

View file

@ -1,7 +1,7 @@
import { parseNumber, getFractionDigits } from '@lion/ui/localize-no-side-effects.js'; import { parseNumber, getFractionDigits } from '@lion/ui/localize-no-side-effects.js';
/** /**
* @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatOptions * @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatNumberOptions
*/ */
/** /**
@ -28,7 +28,7 @@ function round(value, decimals) {
* parseAmount('1,234.56', {currency: 'JOD'}); => 1234.560 * parseAmount('1,234.56', {currency: 'JOD'}); => 1234.560
* *
* @param {string} value Number to be parsed * @param {string} value Number to be parsed
* @param {FormatOptions} [givenOptions] Locale Options * @param {FormatNumberOptions} [givenOptions] Locale Options
*/ */
export function parseAmount(value, givenOptions) { export function parseAmount(value, givenOptions) {
const unmatchedInput = value.match(/[^0-9,.\- ]/g); const unmatchedInput = value.match(/[^0-9,.\- ]/g);
@ -44,7 +44,7 @@ export function parseAmount(value, givenOptions) {
return undefined; return undefined;
} }
/** @type {FormatOptions} */ /** @type {FormatNumberOptions} */
const options = { const options = {
...givenOptions, ...givenOptions,
}; };

View file

@ -1,10 +1,11 @@
import { aTimeout, expect, fixture } from '@open-wc/testing'; import { aTimeout, expect, fixture } from '@open-wc/testing';
import { html } from 'lit'; import { html } from 'lit';
import { localize } from '@lion/ui/localize.js'; import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js';
import { localizeTearDown } from '@lion/ui/localize-test-helpers.js'; import { localizeTearDown } from '@lion/ui/localize-test-helpers.js';
import { getInputMembers } from '@lion/ui/input-test-helpers.js'; import { getInputMembers } from '@lion/ui/input-test-helpers.js';
import { LionInputAmount, formatAmount, parseAmount } from '@lion/ui/input-amount.js'; import { LionInputAmount, formatAmount, parseAmount } from '@lion/ui/input-amount.js';
import { mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
import sinon from 'sinon';
import '@lion/ui/define/lion-input-amount.js'; import '@lion/ui/define/lion-input-amount.js';
/** /**
@ -12,6 +13,8 @@ import '@lion/ui/define/lion-input-amount.js';
*/ */
describe('<lion-input-amount>', () => { describe('<lion-input-amount>', () => {
const localize = getLocalizeManager();
beforeEach(() => { beforeEach(() => {
localizeTearDown(); localizeTearDown();
}); });
@ -128,6 +131,37 @@ describe('<lion-input-amount>', () => {
expect(_inputNode.value).to.equal('100.12'); expect(_inputNode.value).to.equal('100.12');
}); });
it('adjusts formats with locale when formatOptions.mode is "user-edit"', async () => {
const el = /** @type {LionInputAmount} */ (
await fixture(
html`<lion-input-amount
.modelValue=${123456.78}
currency="EUR"
.formatOptions="${{ locale: 'nl-NL' }}"
></lion-input-amount>`,
)
);
const parserSpy = sinon.spy(el, 'parser');
const formatterSpy = sinon.spy(el, 'formatter');
// @ts-expect-error [allow-protected] in test
expect(el._inputNode.value).to.equal('123.456,78');
// When editing an already existing value, we interpet the separators as they are
mimicUserInput(el, '123.456');
expect(parserSpy.lastCall.args[1]?.mode).to.equal('user-edit');
expect(formatterSpy.lastCall.args[1]?.mode).to.equal('user-edit');
expect(el.modelValue).to.equal(123456);
expect(el.formattedValue).to.equal('123.456,00');
// Formatting should only affect values that should be formatted / parsed as a consequence of user input.
// When a user finished editing, the default should be restored.
// (think of a programmatically set modelValue, that should behave idempotent, regardless of when it is set)
el.modelValue = 1234;
expect(el.formattedValue).to.equal('1.234,00');
expect(formatterSpy.lastCall.args[1]?.mode).to.equal('auto');
});
it('sets inputmode attribute to decimal', async () => { it('sets inputmode attribute to decimal', async () => {
const el = /** @type {LionInputAmount} */ ( const el = /** @type {LionInputAmount} */ (
await fixture(`<lion-input-amount></lion-input-amount>`) await fixture(`<lion-input-amount></lion-input-amount>`)

View file

@ -60,4 +60,11 @@ describe('parseAmount()', async () => {
expect(parseAmount('foo1', { mode: 'pasted' })).to.equal(1); expect(parseAmount('foo1', { mode: 'pasted' })).to.equal(1);
expect(parseAmount('EUR 1,50', { mode: 'pasted' })).to.equal(1.5); expect(parseAmount('EUR 1,50', { mode: 'pasted' })).to.equal(1.5);
}); });
it('parses based on locale when "user-edit" mode used', async () => {
expect(parseAmount('123,456.78', { mode: 'auto' })).to.equal(123456.78);
expect(parseAmount('123,456.78', { mode: 'user-edit' })).to.equal(123456.78);
expect(parseAmount('123.456,78', { mode: 'auto' })).to.equal(123456.78);
expect(parseAmount('123.456,78', { mode: 'user-edit' })).to.equal(123.45678);
});
}); });

View file

@ -8,7 +8,7 @@ import { getLocale } from '../utils/getLocale.js';
* @returns {string} The separator * @returns {string} The separator
*/ */
export function getDecimalSeparator(locale, options) { export function getDecimalSeparator(locale, options) {
if (options && options.decimalSeparator) { if (options?.decimalSeparator) {
return options.decimalSeparator; return options.decimalSeparator;
} }
const computedLocale = getLocale(locale); const computedLocale = getLocale(locale);

View file

@ -17,13 +17,15 @@ import { getDecimalSeparator } from './getDecimalSeparator.js';
* *
* @param {string} value Clean number (only [0-9 ,.]) to be parsed * @param {string} value Clean number (only [0-9 ,.]) to be parsed
* @param {object} options * @param {object} options
* @param {string?} [options.mode] auto|pasted * @param {string?} [options.mode] auto|pasted|user-edit
* @return {string} unparseable|withLocale|heuristic * @return {string} unparseable|withLocale|heuristic
*/ */
function getParseMode(value, { mode = 'auto' } = {}) { function getParseMode(value, { mode = 'auto' } = {}) {
const separators = value.match(/[., ]/g); const separators = value.match(/[., ]/g);
if (!separators) { // When a user edits an existin value, we already formatted it with a certain locale.
// For best UX, we stick with this locale
if (!separators || mode === 'user-edit') {
return 'withLocale'; return 'withLocale';
} }
if (mode === 'auto' && separators.length === 1) { if (mode === 'auto' && separators.length === 1) {
@ -52,8 +54,7 @@ function getParseMode(value, { mode = 'auto' } = {}) {
* @param {import('../../types/LocalizeMixinTypes.js').FormatNumberOptions} options Locale Options * @param {import('../../types/LocalizeMixinTypes.js').FormatNumberOptions} options Locale Options
*/ */
function parseWithLocale(value, options) { function parseWithLocale(value, options) {
const locale = options && options.locale ? options.locale : undefined; const separator = getDecimalSeparator(options?.locale, options);
const separator = getDecimalSeparator(locale, options);
const regexNumberAndLocaleSeparator = new RegExp(`[0-9${separator}-]`, 'g'); const regexNumberAndLocaleSeparator = new RegExp(`[0-9${separator}-]`, 'g');
let numberAndLocaleSeparator = value.match(regexNumberAndLocaleSeparator)?.join(''); let numberAndLocaleSeparator = value.match(regexNumberAndLocaleSeparator)?.join('');
if (separator === ',') { if (separator === ',') {

View file

@ -74,6 +74,13 @@ describe('parseNumber()', () => {
expect(parseNumber('123456.78', { mode: 'pasted' })).to.equal(123456.78); expect(parseNumber('123456.78', { mode: 'pasted' })).to.equal(123456.78);
}); });
it('detects separators withLocale when "user-edit" mode used e.g. 123.456,78', async () => {
expect(parseNumber('123,456.78', { mode: 'auto' })).to.equal(123456.78);
expect(parseNumber('123,456.78', { mode: 'user-edit' })).to.equal(123456.78);
expect(parseNumber('123.456,78', { mode: 'auto' })).to.equal(123456.78);
expect(parseNumber('123.456,78', { mode: 'user-edit' })).to.equal(123.45678);
});
it('detects separators unparseable when there are 2 same ones e.g. 1.234.56', () => { it('detects separators unparseable when there are 2 same ones e.g. 1.234.56', () => {
expect(parseNumber('1.234.56')).to.equal(123456); expect(parseNumber('1.234.56')).to.equal(123456);
expect(parseNumber('1,234,56')).to.equal(123456); expect(parseNumber('1,234,56')).to.equal(123456);

View file

@ -21,7 +21,7 @@ export declare interface FormatDateOptions extends Intl.DateTimeFormatOptions {
roundMode?: string; roundMode?: string;
returnIfNaN?: string; returnIfNaN?: string;
mode?: 'pasted' | 'auto'; mode?: 'pasted' | 'auto' | 'user-edit';
postProcessors?: Map<string, DatePostProcessor>; postProcessors?: Map<string, DatePostProcessor>;
} }
@ -41,7 +41,7 @@ export declare interface FormatNumberOptions extends Intl.NumberFormatOptions {
// https://en.wikipedia.org/wiki/Decimal_separator#Digit_grouping // https://en.wikipedia.org/wiki/Decimal_separator#Digit_grouping
// note the half space in there as well // note the half space in there as well
groupSeparator?: ',' | '.' | '' | '_' | ' ' | "'"; groupSeparator?: ',' | '.' | '' | '_' | ' ' | "'";
mode?: 'pasted' | 'auto'; mode?: 'pasted' | 'auto' | 'user-edit';
postProcessors?: Map<string, NumberPostProcessor>; postProcessors?: Map<string, NumberPostProcessor>;
} }