446 lines
17 KiB
JavaScript
446 lines
17 KiB
JavaScript
import { LitElement } from '@lion/core';
|
|
import { parseDate } from '@lion/localize';
|
|
import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
|
|
import sinon from 'sinon';
|
|
import { FormatMixin } from '../src/FormatMixin.js';
|
|
import { Unparseable, Validator } from '../index.js';
|
|
|
|
/**
|
|
* @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
|
|
*/
|
|
|
|
class FormatClass extends FormatMixin(LitElement) {
|
|
get _inputNode() {
|
|
return /** @type {HTMLInputElement} */ (super._inputNode); // casts type
|
|
}
|
|
|
|
render() {
|
|
return html`<slot name="input"></slot>`;
|
|
}
|
|
|
|
set value(newValue) {
|
|
if (this._inputNode) {
|
|
this._inputNode.value = newValue;
|
|
}
|
|
}
|
|
|
|
get value() {
|
|
if (this._inputNode) {
|
|
return this._inputNode.value;
|
|
}
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {FormatClass} formControl
|
|
* @param {?} newViewValue
|
|
*/
|
|
function mimicUserInput(formControl, newViewValue) {
|
|
formControl.value = newViewValue; // eslint-disable-line no-param-reassign
|
|
formControl._inputNode.dispatchEvent(new CustomEvent('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* @param {{tagString?: string, modelValueType?: modelValueType}} [customConfig]
|
|
*/
|
|
export function runFormatMixinSuite(customConfig) {
|
|
const cfg = {
|
|
tagString: null,
|
|
childTagString: null,
|
|
...customConfig,
|
|
};
|
|
|
|
/**
|
|
* Mocks a value for you based on the data type
|
|
* Optionally toggles you a different value
|
|
* for needing to mimic a value-change.
|
|
*/
|
|
function generateValueBasedOnType(opts = {}) {
|
|
const options = { type: cfg.modelValueType, toggleValue: false, viewValue: false, ...opts };
|
|
|
|
switch (options.type) {
|
|
case Number:
|
|
return !options.toggleValue ? '123' : '456';
|
|
case Date:
|
|
// viewValue instead of modelValue, since a Date object is unparseable.
|
|
// Note: this viewValue needs to point to the same date as the
|
|
// default returned modelValue.
|
|
if (options.viewValue) {
|
|
return !options.toggleValue ? '5-5-2005' : '10-10-2010';
|
|
}
|
|
return !options.toggleValue ? parseDate('5-5-2005') : parseDate('10-10-2010');
|
|
case Array:
|
|
return !options.toggleValue ? ['foo', 'bar'] : ['baz', 'yay'];
|
|
case Object:
|
|
return !options.toggleValue ? { foo: 'bar' } : { bar: 'foo' };
|
|
case Boolean:
|
|
return !options.toggleValue;
|
|
case 'email':
|
|
return !options.toggleValue ? 'some-user@ing.com' : 'another-user@ing.com';
|
|
case 'iban':
|
|
return !options.toggleValue ? 'SE3550000000054910000003' : 'CH9300762011623852957';
|
|
default:
|
|
return !options.toggleValue ? 'foo' : 'bar';
|
|
}
|
|
}
|
|
|
|
describe('FormatMixin', async () => {
|
|
/** @type {{d: any}} */
|
|
let elem;
|
|
/** @type {FormatClass} */
|
|
let nonFormat;
|
|
/** @type {FormatClass} */
|
|
let fooFormat;
|
|
|
|
before(async () => {
|
|
if (!cfg.tagString) {
|
|
cfg.tagString = defineCE(FormatClass);
|
|
}
|
|
elem = unsafeStatic(cfg.tagString);
|
|
|
|
nonFormat = await fixture(html`
|
|
<${elem}
|
|
.formatter="${/** @param {?} v */ v => v}"
|
|
.parser="${/** @param {string} v */ v => v}"
|
|
.serializer="${/** @param {?} v */ v => v}"
|
|
.deserializer="${/** @param {string} v */ v => v}"
|
|
>
|
|
<input slot="input">
|
|
</${elem}>
|
|
`);
|
|
|
|
fooFormat = await fixture(html`
|
|
<${elem}
|
|
.formatter="${/** @param {string} value */ value => `foo: ${value}`}"
|
|
.parser="${/** @param {string} value */ value => value.replace('foo: ', '')}"
|
|
.serializer="${/** @param {string} value */ value => `[foo] ${value}`}"
|
|
.deserializer="${/** @param {string} value */ value => value.replace('[foo] ', '')}"
|
|
>
|
|
<input slot="input">
|
|
</${elem}>
|
|
`);
|
|
});
|
|
|
|
it('fires `model-value-changed` for every input triggered by user', async () => {
|
|
const formatEl = /** @type {FormatClass} */ (await fixture(
|
|
html`<${elem}><input slot="input"></${elem}>`,
|
|
));
|
|
|
|
let counter = 0;
|
|
let isTriggeredByUser = false;
|
|
formatEl.addEventListener('model-value-changed', (
|
|
/** @param {CustomEvent} event */ event,
|
|
) => {
|
|
counter += 1;
|
|
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
|
|
});
|
|
|
|
mimicUserInput(formatEl, generateValueBasedOnType());
|
|
expect(counter).to.equal(1);
|
|
expect(isTriggeredByUser).to.be.true;
|
|
|
|
// Counter offset +1 for Date because parseDate created a new Date object
|
|
// when the user changes the value.
|
|
// This will result in a model-value-changed trigger even if the user value was the same
|
|
// TODO: a proper solution would be to add `hasChanged` to input-date, like isSameDate()
|
|
// from calendar utils
|
|
const counterOffset = cfg.modelValueType === Date ? 1 : 0;
|
|
|
|
mimicUserInput(formatEl, generateValueBasedOnType());
|
|
expect(counter).to.equal(1 + counterOffset);
|
|
|
|
mimicUserInput(formatEl, generateValueBasedOnType({ toggleValue: true }));
|
|
expect(counter).to.equal(2 + counterOffset);
|
|
});
|
|
|
|
it('fires `model-value-changed` for every programmatic modelValue change', async () => {
|
|
const el = /** @type {FormatClass} */ (await fixture(
|
|
html`<${elem}><input slot="input"></${elem}>`,
|
|
));
|
|
let counter = 0;
|
|
let isTriggeredByUser = false;
|
|
|
|
el.addEventListener('model-value-changed', event => {
|
|
counter += 1;
|
|
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
|
|
});
|
|
|
|
el.modelValue = 'one';
|
|
expect(counter).to.equal(1);
|
|
expect(isTriggeredByUser).to.be.false;
|
|
|
|
// no change means no event
|
|
el.modelValue = 'one';
|
|
expect(counter).to.equal(1);
|
|
|
|
el.modelValue = 'two';
|
|
expect(counter).to.equal(2);
|
|
});
|
|
|
|
it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => {
|
|
expect(nonFormat.modelValue).to.equal('', 'modelValue initially');
|
|
expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially');
|
|
expect(nonFormat.serializedValue).to.equal('', 'serializedValue initially');
|
|
const generatedValue = generateValueBasedOnType();
|
|
nonFormat.modelValue = generatedValue;
|
|
expect(nonFormat.modelValue).to.equal(generatedValue, 'modelValue as provided');
|
|
expect(nonFormat.formattedValue).to.equal(generatedValue, 'formattedValue synchronized');
|
|
expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized');
|
|
});
|
|
|
|
it('has an input node (like <input>/<textarea>) which holds the formatted (view) value', async () => {
|
|
fooFormat.modelValue = 'string';
|
|
expect(fooFormat.formattedValue).to.equal('foo: string');
|
|
expect(fooFormat.value).to.equal('foo: string');
|
|
expect(fooFormat._inputNode.value).to.equal('foo: string');
|
|
});
|
|
|
|
it('converts modelValue => formattedValue (via this.formatter)', async () => {
|
|
fooFormat.modelValue = 'string';
|
|
expect(fooFormat.formattedValue).to.equal('foo: string');
|
|
expect(fooFormat.serializedValue).to.equal('[foo] string');
|
|
});
|
|
|
|
it('converts modelValue => serializedValue (via this.serializer)', async () => {
|
|
fooFormat.modelValue = 'string';
|
|
expect(fooFormat.serializedValue).to.equal('[foo] string');
|
|
});
|
|
|
|
it('converts formattedValue => modelValue (via this.parser)', async () => {
|
|
fooFormat.formattedValue = 'foo: string';
|
|
expect(fooFormat.modelValue).to.equal('string');
|
|
});
|
|
|
|
it('converts serializedValue => modelValue (via this.deserializer)', async () => {
|
|
fooFormat.serializedValue = '[foo] string';
|
|
expect(fooFormat.modelValue).to.equal('string');
|
|
});
|
|
|
|
it('synchronizes _inputNode.value as a fallback mechanism', async () => {
|
|
// Note that in lion-field, the attribute would be put on <lion-field>, not on <input>
|
|
const formatElem = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem}
|
|
value="string"
|
|
.formatter=${/** @param {string} value */ value => `foo: ${value}`}
|
|
.parser=${/** @param {string} value */ value => value.replace('foo: ', '')}
|
|
.serializer=${/** @param {string} value */ value => `[foo] ${value}`}
|
|
.deserializer=${/** @param {string} value */ value => value.replace('[foo] ', '')}
|
|
>
|
|
<input slot="input" value="string" />
|
|
</${elem}>
|
|
`));
|
|
// Now check if the format/parse/serialize loop has been triggered
|
|
await formatElem.updateComplete;
|
|
expect(formatElem.formattedValue).to.equal('foo: string');
|
|
|
|
expect(formatElem._inputNode.value).to.equal('foo: string');
|
|
|
|
expect(formatElem.serializedValue).to.equal('[foo] string');
|
|
expect(formatElem.modelValue).to.equal('string');
|
|
});
|
|
|
|
it('reflects back formatted value to user on leave', async () => {
|
|
const formatEl = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
|
|
<input slot="input" />
|
|
</${elem}>
|
|
`));
|
|
|
|
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
|
|
const generatedModelValue = generateValueBasedOnType();
|
|
mimicUserInput(formatEl, generatedViewValue);
|
|
expect(formatEl._inputNode.value).to.not.equal(`foo: ${generatedModelValue}`);
|
|
|
|
// user leaves field
|
|
formatEl._inputNode.dispatchEvent(new CustomEvent(formatEl.formatOn, { bubbles: true }));
|
|
await aTimeout(0);
|
|
expect(formatEl._inputNode.value).to.equal(`foo: ${generatedModelValue}`);
|
|
});
|
|
|
|
it('reflects back .formattedValue immediately when .modelValue changed imperatively', async () => {
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
|
|
<input slot="input" />
|
|
</${elem}>
|
|
`));
|
|
// The FormatMixin can be used in conjunction with the ValidateMixin, in which case
|
|
// it can hold errorState (affecting the formatting)
|
|
el.hasFeedbackFor = ['error'];
|
|
|
|
// users types value 'test'
|
|
mimicUserInput(el, 'test');
|
|
expect(el._inputNode.value).to.not.equal('foo: test');
|
|
|
|
// Now see the difference for an imperative change
|
|
el.modelValue = 'test2';
|
|
expect(el._inputNode.value).to.equal('foo: test2');
|
|
});
|
|
|
|
it('works if there is no underlying _inputNode', async () => {
|
|
const tagNoInputString = defineCE(class extends FormatMixin(LitElement) {});
|
|
const tagNoInput = unsafeStatic(tagNoInputString);
|
|
expect(async () => {
|
|
/** @type {FormatClass} */ (await fixture(html`<${tagNoInput}></${tagNoInput}>`));
|
|
}).to.not.throw();
|
|
});
|
|
|
|
describe('parsers/formatters/serializers', () => {
|
|
it('should call the parser|formatter|serializer provided by user', async () => {
|
|
const formatterSpy = sinon.spy(value => `foo: ${value}`);
|
|
const parserSpy = sinon.spy(value => value.replace('foo: ', ''));
|
|
const serializerSpy = sinon.spy(value => `[foo] ${value}`);
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem} .formatter=${formatterSpy} .parser=${parserSpy} .serializer=${serializerSpy} .modelValue=${'test'}>
|
|
<input slot="input">
|
|
</${elem}>
|
|
`));
|
|
expect(formatterSpy.called).to.equal(true);
|
|
expect(serializerSpy.called).to.equal(true);
|
|
|
|
el.formattedValue = 'raw';
|
|
expect(parserSpy.called).to.equal(true);
|
|
});
|
|
|
|
it('should have formatOptions available in formatter', async () => {
|
|
const formatterSpy = sinon.spy(value => `foo: ${value}`);
|
|
const generatedViewValue = /** @type {string} */ (generateValueBasedOnType({
|
|
viewValue: true,
|
|
}));
|
|
await fixture(html`
|
|
<${elem} value="${generatedViewValue}" .formatter="${formatterSpy}"
|
|
.formatOptions="${{ locale: 'en-GB', decimalSeparator: '-' }}">
|
|
<input slot="input" .value="${generatedViewValue}">
|
|
</${elem}>
|
|
`);
|
|
|
|
/** @type {{locale: string, decimalSeparator: string}[]} */
|
|
const spyItem = formatterSpy.args[0];
|
|
const spyArg = spyItem[1];
|
|
expect(spyArg.locale).to.equal('en-GB');
|
|
expect(spyArg.decimalSeparator).to.equal('-');
|
|
});
|
|
|
|
it('will only call the parser for defined values', async () => {
|
|
/** @type {?} */
|
|
const generatedValue = generateValueBasedOnType();
|
|
const parserSpy = sinon.spy();
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem} .parser="${parserSpy}">
|
|
<input slot="input" .value="${generatedValue}">
|
|
</${elem}>
|
|
`));
|
|
|
|
expect(parserSpy.callCount).to.equal(1);
|
|
// This could happen for instance in a reset
|
|
el.modelValue = undefined;
|
|
expect(parserSpy.callCount).to.equal(1);
|
|
// This could happen when the user erases the input value
|
|
mimicUserInput(el, '');
|
|
expect(parserSpy.callCount).to.equal(1);
|
|
});
|
|
|
|
it('will not return Unparseable when empty strings are inputted', async () => {
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem}>
|
|
<input slot="input" value="string">
|
|
</${elem}>
|
|
`));
|
|
// This could happen when the user erases the input value
|
|
mimicUserInput(el, '');
|
|
// For backwards compatibility, we keep the modelValue an empty string here.
|
|
// Undefined would be more appropriate 'conceptually', however
|
|
expect(el.modelValue).to.equal('');
|
|
});
|
|
|
|
it.skip('will only call the formatter for valid values on `user-input-changed` ', async () => {
|
|
const formatterSpy = sinon.spy(value => `foo: ${value}`);
|
|
|
|
const generatedModelValue = generateValueBasedOnType();
|
|
/** @type {?} */
|
|
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
|
|
/** @type {?} */
|
|
const generatedViewValueAlt = generateValueBasedOnType({
|
|
viewValue: true,
|
|
toggleValue: true,
|
|
});
|
|
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem} .formatter=${formatterSpy}>
|
|
<input slot="input" .value="${generatedViewValue}">
|
|
</${elem}>
|
|
`));
|
|
expect(formatterSpy.callCount).to.equal(1);
|
|
|
|
el.hasFeedbackFor.push('error');
|
|
// Ensure hasError is always true by putting a validator on it that always returns false.
|
|
// Setting hasError = true is not enough if the element has errorValidators (uses ValidateMixin)
|
|
// that set hasError back to false when the user input is mimicked.
|
|
|
|
const AlwaysInvalid = class extends Validator {
|
|
static get validatorName() {
|
|
return 'AlwaysInvalid';
|
|
}
|
|
|
|
execute() {
|
|
return true;
|
|
}
|
|
};
|
|
|
|
el.validators = [new AlwaysInvalid()];
|
|
mimicUserInput(el, generatedViewValueAlt);
|
|
|
|
expect(formatterSpy.callCount).to.equal(1);
|
|
// Due to hasError, the formatter should not have ran.
|
|
expect(el.formattedValue).to.equal(generatedViewValueAlt);
|
|
|
|
el.hasFeedbackFor.filter(/** @param {string} type */ type => type !== 'error');
|
|
el.validators = [];
|
|
mimicUserInput(el, generatedViewValue);
|
|
expect(formatterSpy.callCount).to.equal(2);
|
|
|
|
expect(el.formattedValue).to.equal(`foo: ${generatedModelValue}`);
|
|
});
|
|
});
|
|
|
|
describe('Unparseable values', () => {
|
|
it('should convert to Unparseable when wrong value inputted by user', async () => {
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem} .parser=${
|
|
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
|
|
}
|
|
>
|
|
<input slot="input">
|
|
</${elem}>
|
|
`));
|
|
mimicUserInput(el, 'test');
|
|
expect(el.modelValue).to.be.an.instanceof(Unparseable);
|
|
});
|
|
|
|
it('should preserve the viewValue when not parseable', async () => {
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem}
|
|
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
|
|
>
|
|
<input slot="input">
|
|
</${elem}>
|
|
`));
|
|
mimicUserInput(el, 'test');
|
|
expect(el.formattedValue).to.equal('test');
|
|
expect(el.value).to.equal('test');
|
|
});
|
|
|
|
it('should display the viewValue when modelValue is of type Unparseable', async () => {
|
|
const el = /** @type {FormatClass} */ (await fixture(html`
|
|
<${elem}
|
|
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
|
|
>
|
|
<input slot="input">
|
|
</${elem}>
|
|
`));
|
|
el.modelValue = new Unparseable('foo');
|
|
expect(el.value).to.equal('foo');
|
|
});
|
|
});
|
|
});
|
|
}
|