/* eslint-disable max-classes-per-file, no-param-reassign */ // eslint-disable-next-line import/no-extraneous-dependencies import { expect, fixture, html, unsafeStatic, defineCE, aTimeout } from '@open-wc/testing'; // eslint-disable-next-line import/no-extraneous-dependencies import sinon from 'sinon'; import { LitElement } from '@lion/core'; import { ValidateMixin } from '../src/ValidateMixin.js'; import { Unparseable } from '../src/Unparseable.js'; import { Validator } from '../src/Validator.js'; import { ResultValidator } from '../src/ResultValidator.js'; import { Required } from '../src/validators/Required.js'; import { MinLength, MaxLength } from '../src/validators/StringValidators.js'; import { AlwaysValid, AlwaysInvalid, AsyncAlwaysValid, AsyncAlwaysInvalid, } from '../test-helpers/helper-validators.js'; import '../lion-validation-feedback.js'; import { FeedbackMixin } from '../src/FeedbackMixin.js'; export function runValidateMixinSuite(customConfig) { const cfg = { tagString: null, ...customConfig, }; const lightDom = cfg.lightDom || ''; const tagString = cfg.tagString || defineCE( class extends ValidateMixin(LitElement) { static get properties() { return { modelValue: String }; } }, ); const tag = unsafeStatic(tagString); const withInputTagString = cfg.tagString || defineCE( class extends ValidateMixin(LitElement) { connectedCallback() { super.connectedCallback(); this.appendChild(document.createElement('input')); } get _inputNode() { return this.querySelector('input'); } }, ); const withInputTag = unsafeStatic(withInputTagString); 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('validates on initialization (once form field has bootstrapped/initialized)', async () => { const el = await fixture(html` <${tag} .validators=${[new Required()]} >${lightDom} `); expect(el.hasError).to.be.true; }); it('revalidates when ".modelValue" changes', async () => { const el = await fixture(html` <${tag} .validators=${[new AlwaysValid()]} .modelValue=${'myValue'} >${lightDom} `); const validateSpy = sinon.spy(el, 'validate'); el.modelValue = 'x'; expect(validateSpy.callCount).to.equal(1); }); it('revalidates when ".validators" changes', async () => { const el = await fixture(html` <${tag} .validators=${[new AlwaysValid()]} .modelValue=${'myValue'} >${lightDom} `); const validateSpy = sinon.spy(el, 'validate'); el.validators = [new MinLength(3)]; expect(validateSpy.callCount).to.equal(1); }); it('clears current results when ".modelValue" changes', async () => { const el = await fixture(html` <${tag} .validators=${[new AlwaysValid()]} .modelValue=${'myValue'} >${lightDom} `); 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 = await fixture(html` <${tag} .validators=${[alwaysValid]}>${lightDom} `); 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 = await fixture(html` <${tag} .validators=${[new AlwaysValid()]}>${lightDom} `); const isEmptySpy = sinon.spy(el, '__isEmpty'); 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 = await fixture(html` <${tag} .validators=${[new AlwaysValid(), new AsyncAlwaysValid()]}> ${lightDom} `); const syncSpy = sinon.spy(el, '__executeSyncValidators'); 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 { constructor(...args) { super(...args); this.name = 'ResultValidator'; } } let el = await fixture(html` <${tag} .validators=${[new AlwaysValid(), new MyResult()]}> ${lightDom} `); const syncSpy = sinon.spy(el, '__executeSyncValidators'); const resultSpy2 = sinon.spy(el, '__executeResultValidators'); el.modelValue = 'nonEmpty'; expect(syncSpy.calledBefore(resultSpy2)).to.be.true; el = await fixture(html` <${tag} .validators=${[new AsyncAlwaysValid(), new MyResult()]}> ${lightDom} `); const asyncSpy = sinon.spy(el, '__executeAsyncValidators'); const resultSpy = sinon.spy(el, '__executeResultValidators'); 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 = await fixture(html` <${tag} .validators=${[new AlwaysValid(), new AsyncAlwaysInvalid()]}> ${lightDom} `); 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 = await fixture(html` <${tag} .validators=${[new AsyncAlwaysInvalid()]}> ${lightDom} `); el.modelValue = 'nonEmpty'; const validateResolveSpy = sinon.spy(el, '__validateCompleteResolve'); await el.validateComplete; expect(validateResolveSpy.callCount).to.equal(1); }); }); }); describe('Validator Integration', () => { class IsCat extends Validator { constructor(...args) { super(...args); this.name = 'isCat'; this.execute = (modelValue, param) => { const validateString = param && param.number ? `cat${param.number}` : 'cat'; const showError = modelValue !== validateString; return showError; }; } } class OtherValidator extends Validator { constructor(...args) { super(...args); this.name = 'otherValidator'; this.execute = () => true; } } 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} `); 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} `); 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} `); expect(executeSpy.args[0][1]).to.equal(param); }); it('Validators will not be called on empty values', async () => { const el = await fixture(html` <${tag} .validators=${[new IsCat()]}>${lightDom} `); el.modelValue = 'cat'; expect(el.errorStates.isCat).to.be.undefined; el.modelValue = 'dog'; expect(el.errorStates.isCat).to.be.true; el.modelValue = ''; expect(el.errorStates.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 = await fixture(html` <${tag} .validators=${[isCatValidator]} .modelValue=${'cat'} >${lightDom} `); el.modelValue = 'cat'; expect(catSpy.callCount).to.equal(1); isCatValidator.param = 'Garfield'; expect(catSpy.callCount).to.equal(2); }); }); describe('Async Validator Integration', () => { let asyncVPromise; let asyncVResolve; beforeEach(() => { asyncVPromise = new Promise(resolve => { asyncVResolve = resolve; }); }); class IsAsyncCat extends Validator { constructor(param, config) { super(param, config); this.name = 'delayed-cat'; this.async = 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 = await fixture(html` <${tag} .modelValue=${'dog'} .validators=${[new IsAsyncCat()]}> ${lightDom} `); const validator = el.validators[0]; expect(validator instanceof Validator).to.be.true; expect(el.hasError).to.be.false; asyncVResolve(); await aTimeout(); expect(el.hasError).to.be.true; }); it('sets ".isPending/[is-pending]" when validation is in progress', async () => { const el = await fixture(html` <${tag} .modelValue=${'dog'}>${lightDom} `); 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(); expect(el.hasAttribute('is-pending')).to.be.true; asyncVResolve(); await aTimeout(); 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 = await fixture(html` <${tag} .modelValue=${'dog'}> ${lightDom} `); // 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.reset(); 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, 'abort'); const el = await fixture(html` <${tag} .modelValue=${'dog'}> ${lightDom} `); // 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 = await fixture(html` <${tag} .isFocused=${true} .modelValue=${'dog'} .validators=${[asyncV]} .asyncValidateOn=${({ formControl }) => !formControl.isFocused} > ${lightDom} `); expect(asyncVExecuteSpy.called).to.equal(0); el.isFocused = false; el.validate(); expect(asyncVExecuteSpy.called).to.equal(1); }); }); describe('ResultValidator Integration', () => { class MySuccessResultValidator extends ResultValidator { constructor(...args) { super(...args); this.type = 'success'; } // eslint-disable-next-line class-methods-use-this executeOnResults({ regularValidationResult, prevValidationResult }) { const errorOrWarning = v => v.type === 'error' || v.type === 'warning'; const hasErrorOrWarning = !!regularValidationResult.filter(errorOrWarning).length; const prevHadErrorOrWarning = !!prevValidationResult.filter(errorOrWarning).length; return !hasErrorOrWarning && prevHadErrorOrWarning; } } 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` <${tag} .validators=${[resultValidator, validator]} .modelValue=${'myValue'} >${lightDom} `); 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` <${tag} .validators=${[resultValidator, validatorAsync]} .modelValue=${'myValue'} >${lightDom} `); 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 = await fixture(html` <${tag} .validators=${[new MinLength(3), resultValidator]} .modelValue=${'myValue'} >${lightDom} `); const prevValidationResult = el.__prevValidationResult; const regularValidationResult = [ ...el.__syncValidationResult, ...el.__asyncValidationResult, ]; expect(resultValidateSpy.args[0][0]).to.eql({ prevValidationResult, regularValidationResult, }); }); 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 = await fixture(html` <${tag} .validators=${[validator, resultV]} .modelValue=${'myValue'} >${lightDom} `); const /** @type {TotalValidationResult} */ totalValidationResult = el.__validationResult; expect(totalValidationResult).to.eql([resultV, validator]); }); }); describe('Required Validator integration', () => { it('will result in erroneous state when form control is empty', async () => { const el = await fixture(html` <${tag} .validators=${[new Required()]} .modelValue=${''} >${lightDom} `); expect(el.errorStates.Required).to.be.true; expect(el.hasError).to.be.true; el.modelValue = 'foo'; expect(el.errorStates.Required).to.be.undefined; expect(el.hasError).to.be.false; }); it('calls private ".__isEmpty" by default', async () => { const el = await fixture(html` <${tag} .validators=${[new Required()]} .modelValue=${''} >${lightDom} `); const validator = el.validators.find(v => v instanceof Required); const executeSpy = sinon.spy(validator, 'execute'); 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 () => { const customRequiredTagString = defineCE( class extends ValidateMixin(LitElement) { _isEmpty(modelValue) { return modelValue.model === ''; } }, ); const customRequiredTag = unsafeStatic(customRequiredTagString); const el = await fixture(html` <${customRequiredTag} .validators=${[new Required()]} .modelValue=${{ model: 'foo' }} >${lightDom} `); const providedIsEmptySpy = sinon.spy(el, '_isEmpty'); el.modelValue = { model: '' }; expect(providedIsEmptySpy.callCount).to.equal(1); expect(el.errorStates.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 = await fixture(html` <${tag} .validators=${[new Required(), alwaysInvalid]} .modelValue=${''} >${lightDom} `); 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 = await fixture(html` <${withInputTag} .validators=${[new Required()]} .modelValue=${''} >${lightDom} `); expect(el._inputNode.getAttribute('aria-required')).to.equal('true'); el.validators = []; expect(el._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 = await fixture(html` <${preconfTag} .validators=${[new MinLength(3)]} .modelValue=${'12'} >`); expect(el.errorStates.AlwaysInvalid).to.be.true; expect(el.errorStates.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 = await fixture(html` <${altPreconfTag} .modelValue=${'12'} >`); expect(el.errorStates.MinLength).to.be.true; el.defaultValidators[0].param = 2; expect(el.errorStates.MinLength).to.be.undefined; }); it('can be requested via "._allValidators" getter', async () => { const el = await fixture(html` <${preconfTag} .validators=${[new MinLength(3)]} >`); expect(el.validators.length).to.equal(1); expect(el.defaultValidators.length).to.equal(1); expect(el._allValidators.length).to.equal(2); expect(el._allValidators[0] instanceof MinLength).to.be.true; expect(el._allValidators[1] instanceof AlwaysInvalid).to.be.true; el.validators = [new MaxLength(5)]; expect(el._allValidators[0] instanceof MaxLength).to.be.true; expect(el._allValidators[1] instanceof AlwaysInvalid).to.be.true; }); }); describe('State storage and reflection', () => { class ContainsLowercaseA extends Validator { constructor(...args) { super(...args); this.name = 'ContainsLowercaseA'; this.execute = modelValue => !modelValue.includes('a'); } } class ContainsLowercaseB extends Validator { constructor(...args) { super(...args); this.name = 'containsLowercaseB'; this.execute = modelValue => !modelValue.includes('b'); } } it('stores active state in ".hasError"/[has-error] flag', async () => { const el = await fixture(html` <${tag} .validators=${[new MinLength(3)]} >${lightDom} `); el.modelValue = 'a'; expect(el.hasError).to.be.true; await el.updateComplete; expect(el.hasAttribute('has-error')).to.be.true; el.modelValue = 'abc'; expect(el.hasError).to.be.false; await el.updateComplete; expect(el.hasAttribute('has-error')).to.be.false; el.modelValue = 'abcde'; expect(el.hasError).to.be.false; await el.updateComplete; expect(el.hasAttribute('has-error')).to.be.false; el.modelValue = 'abcdefg'; expect(el.hasError).to.be.false; await el.updateComplete; expect(el.hasAttribute('has-error')).to.be.false; }); it('stores validity of individual Validators in ".errorStates[validator.name]"', async () => { const el = await fixture(html` <${tag} .modelValue=${'a'} .validators=${[new MinLength(3), new AlwaysInvalid()]} >${lightDom}`); expect(el.errorStates.MinLength).to.be.true; expect(el.errorStates.AlwaysInvalid).to.be.true; el.modelValue = 'abc'; expect(el.errorStates.MinLength).to.equal(undefined); expect(el.errorStates.AlwaysInvalid).to.be.true; }); it('removes "non active" states whenever modelValue becomes undefined', async () => { const el = await fixture(html` <${tag} .validators=${[new MinLength(3)]} >${lightDom} `); el.modelValue = 'a'; expect(el.hasError).to.be.true; expect(el.errorStates).to.not.eql({}); el.modelValue = undefined; expect(el.hasError).to.be.false; expect(el.errorStates).to.eql({}); }); describe('Events', () => { it('fires "has-error-changed" event when state changes', async () => { const el = await fixture(html` <${tag} .validators=${[new MinLength(7)]} >${lightDom} `); const cbError = sinon.spy(); el.addEventListener('has-error-changed', cbError); el.modelValue = 'a'; await el.updateComplete; expect(cbError.callCount).to.equal(1); el.modelValue = 'abc'; await el.updateComplete; expect(cbError.callCount).to.equal(1); el.modelValue = 'abcde'; await el.updateComplete; expect(cbError.callCount).to.equal(1); el.modelValue = 'abcdefg'; await el.updateComplete; expect(cbError.callCount).to.equal(2); }); it('fires "error-states-changed" event when "internal" state changes', async () => { const el = await fixture(html` <${tag} .validators=${[new MinLength(3), new ContainsLowercaseA(), new ContainsLowercaseB()]} >${lightDom} `); const cbError = sinon.spy(); el.addEventListener('error-states-changed', cbError); el.modelValue = 'a'; await el.updateComplete; expect(cbError.callCount).to.equal(1); el.modelValue = 'aa'; await el.updateComplete; expect(cbError.callCount).to.equal(1); el.modelValue = 'aaa'; await el.updateComplete; expect(cbError.callCount).to.equal(2); el.modelValue = 'aba'; await el.updateComplete; expect(cbError.callCount).to.equal(3); }); }); }); describe('Accessibility', () => { it.skip('calls "._inputNode.setCustomValidity(errorMessage)"', async () => { const el = await fixture(html` <${tag} .modelValue=${'123'} .validators=${[new MinLength(3, { message: 'foo' })]}> `); const spy = sinon.spy(el.inputElement, 'setCustomValidity'); el.modelValue = ''; expect(spy.callCount).to.be(1); expect(el.validationMessage).to.be('foo'); el.modelValue = '123'; expect(spy.callCount).to.be(2); 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 FeedbackMixin(ValidateMixin(LitElement)) { static get validationTypes() { return [...super.validationTypes, 'x', 'y']; } }, ); const customTypeTag = unsafeStatic(customTypeTagString); it('supports multiple "has{Type}" flags', async () => { const el = await fixture(html` <${customTypeTag} .validators=${[ new MinLength(2, { type: 'x' }), new MinLength(3, { type: 'error' }), new MinLength(4, { type: 'y' }), ]} .modelValue=${'1234'} >${lightDom} `); expect(el.hasY).to.be.false; expect(el.hasError).to.be.false; expect(el.hasX).to.be.false; el.modelValue = '123'; // triggers y expect(el.hasY).to.be.true; expect(el.hasError).to.be.false; expect(el.hasX).to.be.false; el.modelValue = '12'; // triggers error and y expect(el.hasY).to.be.true; expect(el.hasError).to.be.true; expect(el.hasX).to.be.false; el.modelValue = '1'; // triggers x, error and y expect(el.hasY).to.be.true; expect(el.hasError).to.be.true; expect(el.hasX).to.be.true; }); it('supports multiple "{type}States" objects', async () => { const el = await fixture(html` <${customTypeTag} .validators=${[ new MinLength(2, { type: 'x' }), new MinLength(3, { type: 'error' }), new MinLength(4, { type: 'y' }), ]} .modelValue=${'1234'} >${lightDom} `); expect(el.yStates).to.eql({}); expect(el.errorStates).to.eql({}); expect(el.xStates).to.eql({}); el.modelValue = '123'; // triggers type1 expect(el.yStates).to.eql({ MinLength: true }); expect(el.errorStates).to.eql({}); expect(el.xStates).to.eql({}); el.modelValue = '12'; // triggers error expect(el.yStates).to.eql({ MinLength: true }); expect(el.errorStates).to.eql({ MinLength: true }); expect(el.xStates).to.eql({}); el.modelValue = '1'; // triggers y expect(el.yStates).to.eql({ MinLength: true }); expect(el.errorStates).to.eql({ MinLength: true }); expect(el.xStates).to.eql({ MinLength: true }); }); it('only shows highest prio "has{Type}Visible" flag by default', async () => { const el = await fixture(html` <${customTypeTag} .validators=${[ new MinLength(2, { type: 'x' }), new MinLength(3), // implicit 'error type' new MinLength(4, { type: 'y' }), ]} .modelValue=${'1234'} >${lightDom} `); expect(el.hasYVisible).to.be.false; expect(el.hasErrorVisible).to.be.false; expect(el.hasXVisible).to.be.false; el.modelValue = '1'; // triggers y, x and error await el.feedbackComplete; expect(el.hasYVisible).to.be.false; // Only shows message with highest prio (determined in el.constructor.validationTypes) expect(el.hasErrorVisible).to.be.true; expect(el.hasXVisible).to.be.false; }); it('orders feedback based on provided "validationTypes"', async () => { const xMinLength = new MinLength(2, { type: 'x' }); const errorMinLength = new MinLength(3, { type: 'error' }); const yMinLength = new MinLength(4, { type: 'y' }); const el = await fixture(html` <${customTypeTag} ._visibleMessagesAmount=${Infinity} .validators=${[xMinLength, errorMinLength, yMinLength]} .modelValue=${''} >${lightDom} `); const prioSpy = sinon.spy(el, '_prioritizeAndFilterFeedback'); el.modelValue = '1'; expect(prioSpy.callCount).to.equal(1); const configuredTypes = el.constructor.validationTypes; // => ['error', 'x', 'y']; const orderedResulTypes = el.__prioritizedResult.map(v => v.type); expect(orderedResulTypes).to.eql(configuredTypes); el.modelValue = '12'; const orderedResulTypes2 = el.__prioritizedResult.map(v => v.type); expect(orderedResulTypes2).to.eql(['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('sends out events for custom types', async () => { const customEventsTagString = defineCE( class extends FeedbackMixin(ValidateMixin(LitElement)) { static get validationTypes() { return [...super.validationTypes, 'x', 'y']; } static get properties() { return { xStates: { type: Object, hasChanged: this._hasObjectChanged, }, hasX: { type: Boolean, attribute: 'has-x', reflect: true, }, hasXVisible: { type: Boolean, attribute: 'has-x-visible', reflect: true, }, yStates: { type: Object, hasChanged: this._hasObjectChanged, }, hasY: { type: Boolean, attribute: 'has-y', reflect: true, }, hasYVisible: { type: Boolean, attribute: 'has-y-visible', reflect: true, }, }; } }, ); const customEventsTag = unsafeStatic(customEventsTagString); const xMinLength = new MinLength(2, { type: 'x' }); const yMinLength = new MinLength(3, { type: 'y' }); const el = await fixture(html` <${customEventsTag} .validators=${[xMinLength, yMinLength]} >${lightDom} `); const xChangedSpy = sinon.spy(); const hasXChangedSpy = sinon.spy(); el.addEventListener('x-states-changed', xChangedSpy); el.addEventListener('has-x-changed', hasXChangedSpy); const yChangedSpy = sinon.spy(); const hasYChangedSpy = sinon.spy(); el.addEventListener('y-states-changed', yChangedSpy); el.addEventListener('has-y-changed', hasYChangedSpy); el.modelValue = '1'; await el.updateComplete; expect(xChangedSpy.callCount).to.equal(1); expect(hasXChangedSpy.callCount).to.equal(1); expect(yChangedSpy.callCount).to.equal(1); expect(hasYChangedSpy.callCount).to.equal(1); const yAlwaysInvalid = new AlwaysInvalid(null, { type: 'y' }); el.validators = [...el.validators, yAlwaysInvalid]; await el.updateComplete; expect(xChangedSpy.callCount).to.equal(1); expect(hasXChangedSpy.callCount).to.equal(1); expect(yChangedSpy.callCount).to.equal(2); // Change within y, since it went from 1 validator to two expect(hasYChangedSpy.callCount).to.equal(1); }); }); describe('Changing feedback visibility conditions', () => { // TODO: add this test on FormControl layer it('reconsiders feedback visibility when interaction states changed', async () => { const interactionTagString = defineCE( class extends FeedbackMixin(ValidateMixin(LitElement)) { static get properties() { return { modelValue: String, dirty: Boolean, touched: Boolean, prefilled: Boolean, submitted: Boolean, }; } }, ); const interactionTag = unsafeStatic(interactionTagString); // see https://codeburst.io/javascript-async-await-with-foreach-b6ba62bbf404 async function asyncForEach(array, callback) { for (let i = 0; i < array.length; i += 1) { // we explicitly want to run it one after each other await callback(array[i], i, array); // eslint-disable-line no-await-in-loop } } const el = await fixture(html` <${interactionTag} .validators=${[new AlwaysValid()]} .modelValue=${'myValue'} >${lightDom} `); const feedbackSpy = sinon.spy(el, '_renderFeedback'); let counter = 0; await asyncForEach(['dirty', 'touched', 'prefilled', 'submitted'], async state => { counter += 1; el[state] = false; await el.updateComplete; expect(feedbackSpy.callCount).to.equal(counter); counter += 1; el[state] = true; await el.updateComplete; expect(feedbackSpy.callCount).to.equal(counter); }); }); it('supports multiple "has{Type}Visible" flags', async () => { const customTypeTagString = defineCE( class extends FeedbackMixin(ValidateMixin(LitElement)) { static get validationTypes() { return [...super.validationTypes, 'x', 'y']; } constructor() { super(); this._visibleMessagesAmount = Infinity; } }, ); const customTypeTag = unsafeStatic(customTypeTagString); const el = await fixture(html` <${customTypeTag} .validators=${[ new MinLength(2, { type: 'x' }), new MinLength(3), // implicit 'error type' new MinLength(4, { type: 'y' }), ]} .modelValue=${'1234'} >${lightDom} `); expect(el.hasYVisible).to.be.false; expect(el.hasErrorVisible).to.be.false; expect(el.hasXVisible).to.be.false; el.modelValue = '1'; // triggers y await el.feedbackComplete; expect(el.hasYVisible).to.be.true; expect(el.hasErrorVisible).to.be.true; expect(el.hasXVisible).to.be.true; // only shows message with highest }); }); describe('Changing feedback messages globally', () => { // Please see tests of Validation Feedback }); }); }); }