@lion/ui
Version:
A package of extendable web components
1,446 lines (1,265 loc) • 55.8 kB
JavaScript
import { LitElement } from 'lit';
import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import {
getFormControlMembers,
AsyncAlwaysInvalid,
AsyncAlwaysValid,
AlwaysInvalid,
AlwaysValid,
} from '@lion/ui/form-core-test-helpers.js';
import sinon from 'sinon';
import {
ResultValidator,
ValidateMixin,
EqualsLength,
Unparseable,
MaxLength,
MinLength,
Validator,
Required,
} from '@lion/ui/form-core.js';
import '@lion/ui/define/lion-validation-feedback.js';
import '@lion/ui/define/lion-field.js';
/**
* @typedef {import('@lion/ui/form-core.js').LionField} LionField
*/
/**
* @param {{tagString?: string | null, lightDom?: string}} [customConfig]
*/
export function runValidateMixinSuite(customConfig) {
const cfg = {
tagString: null,
...customConfig,
};
const lightDom = cfg.lightDom || '';
class ValidateElement extends ValidateMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
const inputNode = document.createElement('input');
inputNode.slot = 'input';
this.appendChild(inputNode);
}
}
const tagString = cfg.tagString || defineCE(ValidateElement);
const tag = unsafeStatic(tagString);
describe('ValidateMixin', () => {
/**
* Terminology
*
* - *validatable-field*
* The element ('this') the ValidateMixin is applied on.
*
* - *input-node*
* The 'this._inputNode' property (usually a getter) that returns/contains a reference to an
* interaction element that receives focus, displays the input value, interaction states are
* derived from, aria properties are put on and setCustomValidity (if applicable) is called on.
* Can be input, textarea, my-custom-slider etc.
*
* - *feedback-node*
* The 'this._feedbackNode' property (usually a getter) that returns/contains a reference to
* the output container for validation feedback. Messages will be written to this element
* based on user-defined or default validity feedback visibility conditions.
*
* - *show-{type}-feedback-condition*
* The 'this.hasErrorVisible value that stores whether the
* feedback for the particular validation type should be shown to the end user.
*/
describe('Validation initiation', () => {
it('throws and console.errors if adding non Validator instances to the validators array', async () => {
// we throw and console error as constructor throw are not visible to the end user
const stub = sinon.stub(console, 'error');
const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`));
const errorMessage =
'Validators array only accepts class instances of Validator. Type "array" found. This may be caused by having multiple installations of "@lion/ui/form-core.js".';
expect(() => {
// @ts-expect-error putting the wrong value on purpose
el.validators = [[new Required()]];
}).to.throw(errorMessage);
expect(stub.args[0][0]).to.equal(errorMessage);
const errorMessage2 =
'Validators array only accepts class instances of Validator. Type "string" found. This may be caused by having multiple installations of "@lion/ui/form-core.js".';
expect(() => {
// @ts-expect-error because we purposely put a wrong type
el.validators = ['required'];
}).to.throw(errorMessage2);
expect(stub.args[1][0]).to.equal(errorMessage2);
stub.restore();
});
it('throws a console error if adding a non supported Validator type', async () => {
// we throw and console error to improve DX
const stub = sinon.stub(console, 'error');
const errorMessage = `This component does not support the validator type "major error" used in "MajorValidator". You may change your validators type or add it to the components "static get validationTypes() {}".`;
class MajorValidator extends Validator {
constructor() {
super();
this.type = 'major error';
}
static get validatorName() {
return 'MajorValidator';
}
}
const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`));
expect(() => {
el.validators = [new MajorValidator()];
}).to.throw(errorMessage);
expect(stub.args[0][0]).to.equal(errorMessage);
stub.restore();
});
it('validates on initialization (once form field has bootstrapped/initialized)', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new Required()]}
>${lightDom}</${tag}>
`)
);
expect(el.hasFeedbackFor).to.deep.equal(['error']);
});
// TODO: keep this use case for backwards compatibility or just required extending Required?
it('determines whether the "Required" validator was already handled by judging the validatorName', async () => {
class BundledValidator extends EventTarget {
static ['_$isValidator$'] = true;
static validatorName = '';
constructor() {
super();
this.type = 'error';
}
get config() {
// simplified version of the actual config
return {};
}
execute() {
// simplified version of the action execution
return true;
}
onFormControlConnect() {}
onFormControlDisconnect() {}
async _getMessage() {
// simplified version of the actual logic
return 'You need to enter something.';
}
}
class BundledRequired extends BundledValidator {
static validatorName = 'Required';
}
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new BundledRequired()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
expect(el.hasFeedbackFor).to.deep.equal([]);
});
it('determines whether the "Required" validator was already handled by judging the validatorName', async () => {
class MyRequired extends Required {
static validatorName = 'SomethingOtherThanRequired';
}
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new MyRequired()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
expect(el.hasFeedbackFor).to.deep.equal([]);
});
it('determines whether the passed Validators are ResultValidators judging by the presence of "executeOnResults"', async () => {
class ValidateElementWithSuccessType extends ValidateElement {
static get validationTypes() {
return ['error', 'success'];
}
}
const elTagString = defineCE(ValidateElementWithSuccessType);
const elTag = unsafeStatic(elTagString);
class BundledValidator extends EventTarget {
static ['_$isValidator$'] = true;
static validatorName = '';
constructor() {
super();
this.type = 'error';
}
get config() {
// simplified version of the actual config
return {};
}
execute() {
// simplified version of the action execution
return true;
}
onFormControlConnect() {}
onFormControlDisconnect() {}
async _getMessage() {
// simplified version of the actual logic
return 'Success message.';
}
}
class BundledDefaultSuccess extends BundledValidator {
constructor() {
super();
this.type = 'success';
}
executeOnResults() {
return true;
}
}
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${elTag}
.validators=${[new Required(), new BundledDefaultSuccess()]}
.modelValue=${'myValue'}
>${lightDom}</${elTag}>
`)
);
expect(el.hasFeedbackFor).to.deep.equal(['success']);
});
it('revalidates when ".modelValue" changes', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
const validateSpy = sinon.spy(el, 'validate');
el.modelValue = 'x';
expect(validateSpy.callCount).to.equal(1);
});
it('revalidates when child ".modelValue" changes', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
._repropagationRole="${'fieldset'}"
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
><lion-field id="child"><input slot="input"></lion-field></${tag}>
`)
);
const validateSpy = sinon.spy(el, 'validate');
/** @type {LionField} */ (el.querySelector('#child')).modelValue = 'test';
await el.updateComplete;
expect(validateSpy.callCount).to.equal(1);
});
it('revalidates when ".validators" changes', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
const validateSpy = sinon.spy(el, 'validate');
el.validators = [new MinLength(3)];
expect(validateSpy.callCount).to.equal(1);
});
it('revalidates when validator "param-changed"', async () => {
const validator = new MinLength(3);
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[validator]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
const validateSpy = sinon.spy(el, 'validate');
expect(validateSpy.callCount).to.equal(0);
validator.param = 4;
expect(validateSpy.callCount).to.equal(1);
});
it('calls "_onValidatorUpdated" when Validator instanced updated', async () => {
const validator = new MinLength(3);
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[validator]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
// @ts-ignore
const spy = sinon.spy(el, '_onValidatorUpdated');
// on param-changed
validator.param = 4;
expect(spy.callCount).to.equal(1);
const [eventArg1, metaArg1] = spy.args[0];
expect(eventArg1).to.be.instanceOf(Event);
expect(eventArg1.type).to.equal('param-changed');
expect(metaArg1).to.eql({ validator });
// on config-changed
validator.config = {};
expect(spy.callCount).to.equal(2);
const [eventArg2, metaArg2] = spy.args[1];
expect(eventArg2).to.be.instanceOf(Event);
expect(eventArg2.type).to.equal('config-changed');
expect(metaArg2).to.eql({ validator });
});
it('clears current results when ".modelValue" changes', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
// @ts-ignore [allow-private] in test
const clearSpy = sinon.spy(el, '__clearValidationResults');
const validateSpy = sinon.spy(el, 'validate');
el.modelValue = 'x';
expect(clearSpy.callCount).to.equal(1);
expect(validateSpy.args[0][0]).to.eql({
clearCurrentResult: true,
});
});
/**
* Inside "Validator integration" we test reinitiation on Validator param change
*/
});
describe('Validation process: internal flow', () => {
it('firstly checks for empty values', async () => {
const alwaysValid = new AlwaysValid();
const alwaysValidExecuteSpy = sinon.spy(alwaysValid, 'execute');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .validators=${[alwaysValid]}>${lightDom}</${tag}>
`)
);
// @ts-ignore [allow-private] in test
const isEmptySpy = sinon.spy(el, '__isEmpty');
const validateSpy = sinon.spy(el, 'validate');
el.modelValue = '';
expect(validateSpy.callCount).to.equal(1);
expect(alwaysValidExecuteSpy.callCount).to.equal(0);
expect(isEmptySpy.callCount).to.equal(1);
el.modelValue = 'nonEmpty';
expect(validateSpy.callCount).to.equal(2);
expect(alwaysValidExecuteSpy.callCount).to.equal(1);
expect(isEmptySpy.callCount).to.equal(2);
});
it('secondly checks for synchronous Validators: creates RegularValidationResult', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .validators=${[new AlwaysValid()]}>${lightDom}</${tag}>
`)
);
// @ts-ignore [allow-private] in test
const isEmptySpy = sinon.spy(el, '__isEmpty');
// @ts-ignore [allow-private] in test
const syncSpy = sinon.spy(el, '__executeSyncValidators');
el.modelValue = 'nonEmpty';
expect(isEmptySpy.calledBefore(syncSpy)).to.be.true;
});
it('thirdly schedules asynchronous Validators: creates RegularValidationResult', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .validators=${[new AlwaysValid(), new AsyncAlwaysValid()]}>
${lightDom}
</${tag}>
`)
);
// @ts-ignore [allow-private] in test
const syncSpy = sinon.spy(el, '__executeSyncValidators');
// @ts-ignore [allow-private] in test
const asyncSpy = sinon.spy(el, '__executeAsyncValidators');
el.modelValue = 'nonEmpty';
expect(syncSpy.calledBefore(asyncSpy)).to.be.true;
});
it('finally checks for ResultValidators: creates TotalValidationResult', async () => {
class MyResult extends ResultValidator {
static get validatorName() {
return 'ResultValidator';
}
}
let el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new AlwaysValid(), new MyResult()]}>
${lightDom}
</${tag}>
`)
);
// @ts-ignore [allow-private] in test
const syncSpy = sinon.spy(el, '__executeSyncValidators');
// @ts-ignore [allow-private] in test
const resultSpy2 = sinon.spy(el, '__executeMetaValidators');
el.modelValue = 'nonEmpty';
expect(syncSpy.calledBefore(resultSpy2)).to.be.true;
el = await fixture(html`
<${tag}
.validators=${[new AsyncAlwaysValid(), new MyResult()]}>
${lightDom}
</${tag}>
`);
// @ts-ignore [allow-private] in test
const asyncSpy = sinon.spy(el, '__executeAsyncValidators');
// @ts-ignore [allow-private] in test
const resultSpy = sinon.spy(el, '__executeMetaValidators');
el.modelValue = 'nonEmpty';
expect(resultSpy.callCount).to.equal(1);
expect(asyncSpy.callCount).to.equal(1);
await el.validateComplete;
expect(resultSpy.callCount).to.equal(2);
});
describe('Finalization', () => {
it('fires private "validate-performed" event on every cycle', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .validators=${[new AlwaysValid(), new AsyncAlwaysInvalid()]}>
${lightDom}
</${tag}>
`)
);
const cbSpy = sinon.spy();
el.addEventListener('validate-performed', cbSpy);
el.modelValue = 'nonEmpty';
expect(cbSpy.callCount).to.equal(1);
});
it('resolves ".validateComplete" Promise', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .validators=${[new AsyncAlwaysInvalid()]}>
${lightDom}
</${tag}>
`)
);
el.modelValue = 'nonEmpty';
// @ts-ignore [allow-private] in test
const validateResolveSpy = sinon.spy(el, '__validateCompleteResolve');
await el.validateComplete;
expect(validateResolveSpy.callCount).to.equal(1);
});
});
});
describe('Validator Integration', () => {
class IsCat extends Validator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
/**
*
* @param {string} modelValue
* @param {{number: number}} [param]
*/
this.execute = (modelValue, param) => {
const validateString = param && param.number ? `cat${param.number}` : 'cat';
const showError = modelValue !== validateString;
return showError;
};
}
static get validatorName() {
return 'IsCat';
}
}
class OtherValidator extends Validator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
this.execute = () => true;
}
static get validatorName() {
return 'otherValidator';
}
}
it('Validators will be called with ".modelValue" as first argument', async () => {
const otherValidator = new OtherValidator();
const otherValidatorSpy = sinon.spy(otherValidator, 'execute');
await fixture(html`
<${tag}
.validators=${[new Required(), otherValidator]}
.modelValue=${'model'}
>${lightDom}</${tag}>
`);
// @ts-expect-error weird sinon type error..
expect(otherValidatorSpy.calledWith('model')).to.be.true;
});
it('Validators will be called with viewValue as first argument when modelValue is unparseable', async () => {
const otherValidator = new OtherValidator();
const otherValidatorSpy = sinon.spy(otherValidator, 'execute');
await fixture(html`
<${tag}
.validators=${[new Required(), otherValidator]}
.modelValue=${new Unparseable('view')}
>${lightDom}</${tag}>
`);
// @ts-expect-error weird sinon type error..
expect(otherValidatorSpy.calledWith('view')).to.be.true;
});
it('Validators will be called with param as a second argument', async () => {
const param = { number: 5 };
const validator = new IsCat(param);
const executeSpy = sinon.spy(validator, 'execute');
await fixture(html`
<${tag}
.validators=${[validator]}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`);
expect(executeSpy.args[0][1]).to.equal(param);
});
it('Validators will be called with a config that has { node } as a third argument', async () => {
const validator = new IsCat();
const executeSpy = sinon.spy(validator, 'execute');
const el = await fixture(html`
<${tag}
.validators=${[validator]}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`);
// @ts-expect-error another sinon type problem
expect(executeSpy.args[0][2].node).to.equal(el);
});
it('Validators will not be called on empty values', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .validators=${[new IsCat()]}>${lightDom}</${tag}>
`)
);
el.modelValue = 'cat';
expect(el.validationStates.error.IsCat).to.be.undefined;
el.modelValue = 'dog';
expect(el.validationStates.error.IsCat).to.be.true;
el.modelValue = '';
expect(el.validationStates.error.IsCat).to.be.undefined;
});
it('Validators get retriggered on parameter change', async () => {
const isCatValidator = new IsCat('Felix');
const catSpy = sinon.spy(isCatValidator, 'execute');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[isCatValidator]}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`)
);
el.modelValue = 'cat';
expect(catSpy.callCount).to.equal(1);
isCatValidator.param = 'Garfield';
expect(catSpy.callCount).to.equal(2);
});
});
describe('Async Validator Integration', () => {
/** @type {Promise<any>} */
let asyncVPromise;
/** @type {function} */
let asyncVResolve;
beforeEach(() => {
asyncVPromise = new Promise(resolve => {
asyncVResolve = resolve;
});
});
class IsAsyncCat extends Validator {
static get validatorName() {
return 'delayed-cat';
}
static get async() {
return true;
}
/**
* @desc the function that determines the validator. It returns true when
* the Validator is "active", meaning its message should be shown.
* @param {string} modelValue
*/
async execute(modelValue) {
await asyncVPromise;
const hasError = modelValue !== 'cat';
return hasError;
}
}
// default execution trigger is keyup (think of password availability backend)
// can configure execution trigger (blur, etc?)
it('handles "execute" functions returning promises', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.modelValue=${'dog'}
.validators=${[new IsAsyncCat()]}>
${lightDom}
</${tag}>
`)
);
const validator = el.validators[0];
expect(validator instanceof Validator).to.be.true;
expect(el.hasFeedbackFor).to.deep.equal([]);
asyncVResolve();
await aTimeout(0);
expect(el.hasFeedbackFor).to.deep.equal(['error']);
});
it('sets ".isPending/[is-pending]" when validation is in progress', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .modelValue=${'dog'}>${lightDom}</${tag}>
`)
);
expect(el.isPending).to.be.false;
expect(el.hasAttribute('is-pending')).to.be.false;
el.validators = [new IsAsyncCat()];
expect(el.isPending).to.be.true;
await aTimeout(0);
expect(el.hasAttribute('is-pending')).to.be.true;
asyncVResolve();
await aTimeout(0);
expect(el.isPending).to.be.false;
expect(el.hasAttribute('is-pending')).to.be.false;
});
// TODO: 'mock' these methods without actually waiting for debounce?
it.skip('debounces async validation for performance', async () => {
const asyncV = new IsAsyncCat();
const asyncVExecuteSpy = sinon.spy(asyncV, 'execute');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .modelValue=${'dog'}>
${lightDom}
</${tag}>
`)
);
// debounce started
el.validators = [asyncV];
expect(asyncVExecuteSpy.called).to.equal(0);
// TODO: consider wrapping debounce in instance/ctor function to make spying possible
// await debounceFinish
expect(asyncVExecuteSpy.called).to.equal(1);
// New validation cycle. Now change modelValue inbetween, so validation is retriggered.
asyncVExecuteSpy.resetHistory();
el.modelValue = 'dogger';
expect(asyncVExecuteSpy.called).to.equal(0);
el.modelValue = 'doggerer';
// await original debounce period...
expect(asyncVExecuteSpy.called).to.equal(0);
// await original debounce again without changing mv inbetween...
expect(asyncVExecuteSpy.called).to.equal(1);
});
// TODO: nice to have...
it.skip('developer can configure debounce on FormControl instance', async () => {});
it.skip('cancels and reschedules async validation on ".modelValue" change', async () => {
const asyncV = new IsAsyncCat();
const asyncVAbortSpy = sinon.spy(asyncV, 'abortExecution');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag} .modelValue=${'dog'}>
${lightDom}
</${tag}>
`)
);
// debounce started
el.validators = [asyncV];
expect(asyncVAbortSpy.called).to.equal(0);
el.modelValue = 'dogger';
// await original debounce period...
expect(asyncVAbortSpy.called).to.equal(1);
});
// TODO: nice to have
it.skip('developer can configure condition for asynchronous validation', async () => {
const asyncV = new IsAsyncCat();
const asyncVExecuteSpy = sinon.spy(asyncV, 'execute');
const el = /** @type {ValidateElement & { isFocused: boolean }} */ (
await fixture(html`
<${tag}
.isFocused=${true}
.modelValue=${'dog'}
.validators=${[asyncV]}
.asyncValidateOn=${
/** @param {{ formControl: { isFocused: boolean } }} opts */ ({ formControl }) =>
!formControl.isFocused
}
>
${lightDom}
</${tag}>
`)
);
expect(asyncVExecuteSpy.called).to.equal(0);
el.isFocused = false;
el.validate();
expect(asyncVExecuteSpy.called).to.equal(1);
});
});
describe('ResultValidator Integration', () => {
const MySuccessResultValidator = class extends ResultValidator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
this.type = 'success';
}
/**
*
* @param {Object} context
* @param {Validator[]} context.regularValidationResult
* @param {Validator[]} context.prevShownValidationResult
* @returns {boolean}
*/
// eslint-disable-next-line class-methods-use-this
executeOnResults({ regularValidationResult, prevShownValidationResult }) {
const errorOrWarning = /** @param {Validator} v */ v =>
v.type === 'error' || v.type === 'warning';
const hasErrorOrWarning = !!regularValidationResult.filter(errorOrWarning).length;
const hasShownErrorOrWarning = !!prevShownValidationResult.filter(errorOrWarning).length;
return !hasErrorOrWarning && hasShownErrorOrWarning;
}
};
const withSuccessTagString = defineCE(
class extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'success'];
}
},
);
const withSuccessTag = unsafeStatic(withSuccessTagString);
it('calls ResultValidators after regular validators', async () => {
const resultValidator = new MySuccessResultValidator();
const resultValidateSpy = sinon.spy(resultValidator, 'executeOnResults');
// After regular sync Validators
const validator = new MinLength(3);
const validateSpy = sinon.spy(validator, 'execute');
await fixture(html`
<${withSuccessTag}
.validators=${[resultValidator, validator]}
.modelValue=${'myValue'}
>${lightDom}</${withSuccessTag}>
`);
expect(validateSpy.calledBefore(resultValidateSpy)).to.be.true;
// Also after regular async Validators
const validatorAsync = new AsyncAlwaysInvalid();
const validateAsyncSpy = sinon.spy(validatorAsync, 'execute');
await fixture(html`
<${withSuccessTag}
.validators=${[resultValidator, validatorAsync]}
.modelValue=${'myValue'}
>${lightDom}</${withSuccessTag}>
`);
expect(validateAsyncSpy.calledBefore(resultValidateSpy)).to.be.true;
});
it(`provides "regular" ValidationResult and previous FinalValidationResult as input to
"executeOnResults" function`, async () => {
const resultValidator = new MySuccessResultValidator();
const resultValidateSpy = sinon.spy(resultValidator, 'executeOnResults');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${withSuccessTag}
.validators=${[new MinLength(3), resultValidator]}
.modelValue=${'myValue'}
>${lightDom}</${withSuccessTag}>
`)
);
// @ts-ignore [allow-private] in test
const prevValidationResult = el.__prevValidationResult;
// @ts-ignore [allow-private] in test
const prevShownValidationResult = el.__prevShownValidationResult;
const regularValidationResult = [
// @ts-ignore [allow-private] in test
...el.__syncValidationResult,
// @ts-ignore [allow-private] in test
...el.__asyncValidationResult,
];
expect(resultValidateSpy.args[0][0]).to.eql({
regularValidationResult,
prevValidationResult,
prevShownValidationResult,
});
});
it('adds ResultValidator outcome as highest prio result to the FinalValidationResult', async () => {
class AlwaysInvalidResult extends ResultValidator {
// eslint-disable-next-line class-methods-use-this
executeOnResults() {
const hasError = true;
return hasError;
}
}
const validator = new AlwaysInvalid();
const resultV = new AlwaysInvalidResult();
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[validator, resultV]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`)
);
// @ts-ignore [allow-private] in test
const totalValidationResult = el.__validationResult;
expect(totalValidationResult).to.eql([
{ validator: resultV, outcome: true },
{ validator, outcome: true },
]);
});
});
describe('getMessages with custom HTML', () => {
it('will render HTML for lion-validation-feedback', async () => {
const messageHtmlId = 'test123';
const messageHtml = `<div id="test123">test</div>`;
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators="${[
// @ts-expect-error
new EqualsLength(4, { getMessage: () => html`<div id="test123">test</div>` }),
]}" })]}"
.modelValue="${'123'}"
label="Custom message for validator instance"
></${tag}>
`)
);
expect(el.validationStates.error.EqualsLength).to.be.true;
expect(el.hasFeedbackFor).to.deep.equal(['error']);
const { _feedbackNode } = getFormControlMembers(el);
await el.updateComplete;
await _feedbackNode.updateComplete;
const messageHtmlNode = _feedbackNode.shadowRoot?.querySelector(`#${messageHtmlId}`);
expect(messageHtmlNode?.outerHTML).to.equal(messageHtml);
expect(messageHtmlNode?.tagName).to.equal('DIV');
});
});
describe('Required Validator integration', () => {
it('will result in erroneous state when form control is empty', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new Required()]}
.modelValue=${''}
>${lightDom}</${tag}>
`)
);
expect(el.validationStates.error.Required).to.be.true;
expect(el.hasFeedbackFor).to.deep.equal(['error']);
el.modelValue = 'foo';
expect(el.validationStates.error.Required).to.be.undefined;
expect(el.hasFeedbackFor).to.deep.equal([]);
});
it('calls private ".__isEmpty" by default', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new Required()]}
.modelValue=${''}
>${lightDom}</${tag}>
`)
);
const validator = /** @type {Validator} */ (el.validators.find(v => v instanceof Required));
const executeSpy = sinon.spy(validator, 'execute');
// @ts-ignore [allow-private] in test
const privateIsEmptySpy = sinon.spy(el, '__isEmpty');
el.modelValue = null;
expect(executeSpy.callCount).to.equal(0);
expect(privateIsEmptySpy.callCount).to.equal(1);
});
it('calls "._isEmpty" when provided (useful for different modelValues)', async () => {
class _isEmptyValidate extends ValidateMixin(LitElement) {
_isEmpty() {
return this.modelValue.model === '';
}
}
const customRequiredTagString = defineCE(_isEmptyValidate);
const customRequiredTag = unsafeStatic(customRequiredTagString);
const el = /** @type {_isEmptyValidate} */ (
await fixture(html`
<${customRequiredTag}
.validators=${[new Required()]}
.modelValue=${{ model: 'foo' }}
>${lightDom}</${customRequiredTag}>
`)
);
const providedIsEmptySpy = sinon.spy(el, '_isEmpty');
el.modelValue = { model: '' };
expect(providedIsEmptySpy.callCount).to.equal(1);
expect(el.validationStates.error.Required).to.be.true;
});
it('prevents other Validators from being called when input is empty', async () => {
const alwaysInvalid = new AlwaysInvalid();
const alwaysInvalidSpy = sinon.spy(alwaysInvalid, 'execute');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new Required(), alwaysInvalid]}
.modelValue=${''}
>${lightDom}</${tag}>
`)
);
expect(alwaysInvalidSpy.callCount).to.equal(0); // __isRequired returned false (invalid)
el.modelValue = 'foo';
expect(alwaysInvalidSpy.callCount).to.equal(1); // __isRequired returned true (valid)
});
it('adds [aria-required="true"] to "._inputNode"', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new Required()]}
.modelValue=${''}
>${lightDom}</${tag}>
`)
);
const { _inputNode } = getFormControlMembers(el);
expect(_inputNode?.getAttribute('aria-required')).to.equal('true');
el.validators = [];
expect(_inputNode?.getAttribute('aria-required')).to.be.null;
});
});
describe('Default (preconfigured) Validators', () => {
const preconfTagString = defineCE(
class extends ValidateMixin(LitElement) {
constructor() {
super();
this.defaultValidators = [new AlwaysInvalid()];
}
},
);
const preconfTag = unsafeStatic(preconfTagString);
it('can be stored for custom inputs', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${preconfTag}
.validators=${[new MinLength(3)]}
.modelValue=${'12'}
></${preconfTag}>`)
);
expect(el.validationStates.error.AlwaysInvalid).to.be.true;
expect(el.validationStates.error.MinLength).to.be.true;
});
it('can be altered by App Developers', async () => {
const altPreconfTagString = defineCE(
class extends ValidateMixin(LitElement) {
constructor() {
super();
this.defaultValidators = [new MinLength(3)];
}
},
);
const altPreconfTag = unsafeStatic(altPreconfTagString);
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${altPreconfTag}
.modelValue=${'12'}
></${altPreconfTag}>`)
);
expect(el.validationStates.error.MinLength).to.be.true;
el.defaultValidators[0].param = 2;
expect(el.validationStates.error.MinLength).to.be.undefined;
});
it('can be requested via "._allValidators" getter', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${preconfTag}
.validators=${[new MinLength(3)]}
></${preconfTag}>`)
);
const { _allValidators } = getFormControlMembers(el);
expect(el.validators.length).to.equal(1);
expect(el.defaultValidators.length).to.equal(1);
expect(_allValidators.length).to.equal(2);
expect(_allValidators[0] instanceof MinLength).to.be.true;
expect(_allValidators[1] instanceof AlwaysInvalid).to.be.true;
el.validators = [new MaxLength(5)];
const { _allValidators: _allValidatorsMl } = getFormControlMembers(el);
expect(_allValidatorsMl[0] instanceof MaxLength).to.be.true;
expect(_allValidatorsMl[1] instanceof AlwaysInvalid).to.be.true;
});
});
describe('State storage and reflection', () => {
it('stores validity of individual Validators in ".validationStates.error[validator.validatorName]"', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.modelValue=${'a'}
.validators=${[new MinLength(3), new AlwaysInvalid()]}
>${lightDom}</${tag}>`)
);
expect(el.validationStates.error.MinLength).to.be.true;
expect(el.validationStates.error.AlwaysInvalid).to.be.true;
el.modelValue = 'abc';
expect(el.validationStates.error.MinLength).to.equal(undefined);
expect(el.validationStates.error.AlwaysInvalid).to.be.true;
});
it('removes "non active" states whenever modelValue becomes undefined', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${[new MinLength(3)]}
>${lightDom}</${tag}>
`)
);
el.modelValue = 'a';
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.not.eql({});
el.modelValue = undefined;
expect(el.hasFeedbackFor).to.deep.equal([]);
expect(el.validationStates.error).to.eql({});
});
it('clears current validation results when validators array updated', async () => {
const validators = [new Required()];
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators=${validators}
>${lightDom}</${tag}>
`)
);
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.eql({ Required: true });
el.validators = [];
expect(el.hasFeedbackFor).to.not.deep.equal(['error']);
expect(el.validationStates.error).to.eql({});
el.validators = [new Required()];
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.not.eql({});
});
it('can be configured to change visibility conditions per type', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.validators="${[new Required({}, { type: 'error' })]}"
.feedbackCondition="${(
/** @type {string} */ type,
/** @type {object} */ meta,
/** @type {(type: string) => any} */ defaultCondition,
) => {
if (type === 'error') {
return true;
}
return defaultCondition(type);
}}"
>${lightDom}</${tag}>
`)
);
expect(el.showsFeedbackFor).to.eql(['error']);
});
describe('Events', () => {
it('fires "showsFeedbackForChanged" event async after feedbackData got synced to feedbackElement', async () => {
const spy = sinon.spy();
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(7)]}
@showsFeedbackForChanged=${spy}
>${lightDom}</${tag}>
`)
);
el.modelValue = 'a';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abc';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abcdefg';
await el.updateComplete;
expect(spy).to.have.callCount(2);
});
it('fires "showsFeedbackFor{type}Changed" event async when type visibility changed', async () => {
const spy = sinon.spy();
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(7)]}
@showsFeedbackForErrorChanged=${spy}
>${lightDom}</${tag}>
`)
);
el.modelValue = 'a';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abc';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abcdefg';
await el.updateComplete;
expect(spy).to.have.callCount(2);
});
it('fires "{type}StateChanged" event async when type validity changed', async () => {
const spy = sinon.spy();
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(7)]}
@errorStateChanged=${spy}
>${lightDom}</${tag}>
`)
);
expect(spy).to.have.callCount(0);
el.modelValue = 'a';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abc';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abcdefg';
await el.updateComplete;
expect(spy).to.have.callCount(2);
});
});
});
describe('Accessibility', () => {
it.skip('calls "._inputNode.setCustomValidity(errorMessage)"', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.modelValue=${'123'}
.validators=${[new MinLength(3, { getMessage: async () => 'foo' })]}>
<input slot="input">
</${tag}>`)
);
const { _inputNode } = getFormControlMembers(el);
if (_inputNode) {
// @ts-ignore -- we are testing the protected _inputNode here
const spy = sinon.spy(el._inputNode, 'setCustomValidity');
el.modelValue = '';
expect(spy.callCount).to.equal(1);
// TODO: why are we testing validationMessage here?
// @ts-ignore
expect(el.validationMessage).to.be('foo');
el.modelValue = '123';
expect(spy.callCount).to.equal(2);
// @ts-ignore
expect(el.validationMessage).to.be('');
}
});
// TODO: check with open a11y issues and find best solution here
it.skip(`removes validity message from DOM instead of toggling "display:none", to trigger Jaws
and VoiceOver [to-be-implemented]`, async () => {});
});
describe('Extensibility: Custom Validator types', () => {
const customTypeTagString = defineCE(
class extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x', 'y'];
}
},
);
const customTypeTag = unsafeStatic(customTypeTagString);
it('supports additional validationTypes in .hasFeedbackFor', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${customTypeTag}
.validators=${[
new MinLength(2, { type: 'x' }),
new MinLength(3, { type: 'error' }),
new MinLength(4, { type: 'y' }),
]}
.modelValue=${'1234'}
>${lightDom}</${customTypeTag}>
`)
);
expect(el.hasFeedbackFor).to.deep.equal([]);
el.modelValue = '123'; // triggers y
expect(el.hasFeedbackFor).to.deep.equal(['y']);
el.modelValue = '12'; // triggers error and y
expect(el.hasFeedbackFor).to.deep.equal(['error', 'y']);
el.modelValue = '1'; // triggers x, error and y
expect(el.hasFeedbackFor).to.deep.equal(['x', 'error', 'y']);
});
it('supports additional validationTypes in .validationStates', async () => {
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${customTypeTag}
.validators=${[
new MinLength(2, { type: 'x' }),
new MinLength(3, { type: 'error' }),
new MinLength(4, { type: 'y' }),
]}
.modelValue=${'1234'}
>${lightDom}</${customTypeTag}>
`)
);
expect(el.validationStates).to.eql({
x: {},
error: {},
y: {},
});
el.modelValue = '123'; // triggers y
expect(el.validationStates).to.eql({
x: {},
error: {},
y: { MinLength: true },
});
el.modelValue = '12'; // triggers error and y
expect(el.validationStates).to.eql({
x: {},
error: { MinLength: true },
y: { MinLength: true },
});
el.modelValue = '1'; // triggers x, error and y
expect(el.validationStates).to.eql({
x: { MinLength: true },
error: { MinLength: true },
y: { MinLength: true },
});
});
it('orders feedback based on provided "validationTypes"', async () => {
// we set submitted to always show error message in the test
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${customTypeTag}
.submitted=${true}
._visibleMessagesAmount=${Infinity}
.validators=${[
new MinLength(2, { type: 'x' }),
new MinLength(3, { type: 'error' }),
new MinLength(4, { type: 'y' }),
]}
.modelValue=${'1'}
>${lightDom}</${customTypeTag}>
`)
);
const { _feedbackNode } = getFormControlMembers(el);
await el.feedbackComplete;
const feedbackNode =
/** @type {import('../src/validate/LionValidationFeedback.js').LionValidationFeedback} */
(_feedbackNode);
const resultOrder = feedbackNode.feedbackData?.map(v => v.type);
expect(resultOrder).to.deep.equal(['error', 'x', 'y']);
el.modelValue = '12';
await el.updateComplete;
await el.feedbackComplete;
const resultOrder2 = feedbackNode.feedbackData?.map(v => v.type);
expect(resultOrder2).to.deep.equal(['error', 'y']);
});
/**
* Out of scope:
* - automatic reflection of attrs (we would need to add to constructor.properties). See
* 'Subclassers' for an example on how to do this
*/
});
describe('Subclassers', () => {
describe('Adding new Validator types', () => {
it('can add helpers for validation types', async () => {
class ValidateHasX extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x'];
}
get hasX() {
return this.hasFeedbackFor.includes('x');
}
get hasXVisible() {
return this.showsFeedbackFor.includes('x');
}
}
const elTagString = defineCE(ValidateHasX);
const elTag = unsafeStatic(elTagString);
// we set submitted to always show errors
const el = /** @type {ValidateHasX} */ (
await fixture(html`
<${elTag}
.submitted=${true}
.validators=${[new MinLength(2, { type: 'x' })]}
.modelValue=${'1'}
>${lightDom}</${elTag}>
`)
);
await el.feedbackComplete;
expect(el.hasX).to.be.true;
expect(el.hasXVisible).to.be.true;
el.modelValue = '12';
expect(el.hasX).to.be.false;
await el.updateComplete;
await el.feedbackComplete;
expect(el.hasXVisible).to.be.false;