diff --git a/packages/validate/docs/FlowDiagram.md b/packages/validate/docs/FlowDiagram.md new file mode 100644 index 000000000..366b73982 --- /dev/null +++ b/packages/validate/docs/FlowDiagram.md @@ -0,0 +1,17 @@ + + +```mermaid +graph TD + A(value changed) --> validate + B(validators changed) --> validate +``` + +```mermaid +graph TD + validate --> B{Check value} + B -->|is empty| C[Run required validator] + B -->|is not empty| syncOrAsync[non empty value] + syncOrAsync -->|has sync validators| F[Run sync] + syncOrAsync -->|has async validators| G((debounce)) + G --> H[Run async] +``` diff --git a/packages/validate/index.js b/packages/validate/index.js index 3548a9452..cea73b19e 100644 --- a/packages/validate/index.js +++ b/packages/validate/index.js @@ -1,40 +1,26 @@ export { ValidateMixin } from './src/ValidateMixin.js'; +export { FeedbackMixin } from './src/FeedbackMixin.js'; export { Unparseable } from './src/Unparseable.js'; -export { isValidatorApplied } from './src/isValidatorApplied.js'; +export { Validator } from './src/Validator.js'; +export { ResultValidator } from './src/ResultValidator.js'; + +export { loadDefaultFeedbackMessages } from './src/loadDefaultFeedbackMessages.js'; + +export { Required } from './src/validators/Required.js'; export { - defaultOk, - defaultOkValidator, - isDateDisabled, - isDateDisabledValidator, - equalsLength, - equalsLengthValidator, - isDate, - isDateValidator, - isEmail, - isEmailValidator, - isNumber, - isNumberValidator, - isString, - isStringValidator, - maxDate, - maxDateValidator, - maxLength, - maxLengthValidator, - maxNumber, - maxNumberValidator, - minDate, - minDateValidator, - minLength, - minLengthValidator, - minMaxDate, - minMaxDateValidator, - minMaxLength, - minMaxLengthValidator, - minMaxNumber, - minMaxNumberValidator, - minNumber, - minNumberValidator, - randomOk, - randomOkValidator, -} from './src/validators.js'; + IsString, + EqualsLength, + MinLength, + MaxLength, + MinMaxLength, + IsEmail, +} from './src/validators/StringValidators.js'; + +export { + IsDate, + MinDate, + MaxDate, + MinMaxDate, + IsDateDisabled, +} from './src/validators/DateValidators.js'; diff --git a/packages/validate/lion-validation-feedback.js b/packages/validate/lion-validation-feedback.js new file mode 100644 index 000000000..d4213c41c --- /dev/null +++ b/packages/validate/lion-validation-feedback.js @@ -0,0 +1,3 @@ +import { LionValidationFeedback } from './src/LionValidationFeedback.js'; + +customElements.define('lion-validation-feedback', LionValidationFeedback); diff --git a/packages/validate/package.json b/packages/validate/package.json index 78e3691db..32765c98d 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -29,6 +29,7 @@ "stories", "test", "test-helpers", + "test-suites", "translations", "*.js" ], diff --git a/packages/validate/src/FeedbackMixin.js b/packages/validate/src/FeedbackMixin.js new file mode 100644 index 000000000..817e3a7f0 --- /dev/null +++ b/packages/validate/src/FeedbackMixin.js @@ -0,0 +1,230 @@ +/* eslint-disable class-methods-use-this, camelcase, no-param-reassign */ + +import { dedupeMixin, SlotMixin } from '@lion/core'; +import { localize } from '@lion/localize'; +import { pascalCase } from './utils/pascal-case.js'; +import { SyncUpdatableMixin } from './utils/SyncUpdatableMixin.js'; +import '../lion-validation-feedback.js'; + +/* + * @desc Handles all UI/dom integration with regard to validation reporting, + * feedback visibility and accessibility. + * Should be used on top of ValidateMixin. + */ +export const FeedbackMixin = dedupeMixin( + superclass => + // eslint-disable-next-line no-unused-vars, no-shadow + class FeedbackMixin extends SyncUpdatableMixin(SlotMixin(superclass)) { + static get properties() { + return { + /** + * @desc Derived from the result of _prioritizeAndFilterFeedback + * @type {boolean} + * @example + * FormControl.hasError; // => true + * FormControl.hasErrorVisible; // => false + * // Interaction state changes (for instance: user blurs the field) + * FormControl.hasErrorVisible; // => true + */ + hasErrorVisible: { + type: Boolean, + attribute: 'has-error-visible', + reflect: true, + }, + /** + * Subclassers can enable this to show multiple feedback messages at the same time + * By default, just like the platform, only one message (with highest prio) is visible. + */ + _visibleMessagesAmount: Number, + + /** + * @type {Promise|string} will be passed as an argument to the `.getMessage` + * method of a Validator. When filled in, this field namme can be used to enhance + * error messages. + */ + fieldName: String, + }; + } + + /** + * @overridable + * Adds "._feedbackNode" as described below + */ + get slots() { + return { + ...super.slots, + feedback: () => document.createElement('lion-validation-feedback'), + }; + } + + /** + * @overridable + * @type {Element} _feedbackNode: + * Gets a `FeedbackData` object as its input. + * This element can be a custom made (web) component that renders messages in accordance with + * the implemented Design System. For instance, it could add an icon in front of a message. + * The _feedbackNode is only responsible for the visual rendering part, it should NOT contain + * state. All state will be determined by the outcome of `FormControl.filterFeeback()`. + * FormControl delegates to individual sub elements and decides who renders what. + * For instance, FormControl itself is responsible for reflecting error-state and error-show + * to its host element. + * This means filtering out messages should happen in FormControl and NOT in `_feedbackNode` + * + * - gets a FeedbackData object as input + * - should know about the FeedbackMessage types('error', 'success' etc.) that the FormControl + * (having ValidateMixin applied) returns + * - renders result and + * + */ + get _feedbackNode() { + return this.querySelector('[slot=feedback]'); + } + + /** + * @abstract get _inputNode() + */ + + constructor() { + super(); + this.hasErrorVisible = false; + this._visibleMessagesAmount = 1; + + this._renderFeedback = this._renderFeedback.bind(this); + this.addEventListener('validate-performed', this._renderFeedback); + } + + connectedCallback() { + super.connectedCallback(); + // TODO: move to extending layer + localize.addEventListener('localeChanged', this._renderFeedback); + } + + disconnectedCallback() { + super.disconnectedCallback(); + // TODO: move to extending layer + localize.removeEventListener('localeChanged', this._renderFeedback); + } + + updateSync(name, oldValue) { + super.updateSync(name, oldValue); + + if (name === 'hasErrorVisible') { + // This can't be reflected asynchronously in Safari + this.__handleA11yErrorVisible(); + this[this.hasErrorVisible ? 'setAttribute' : 'removeAttribute']('has-error-visible', ''); + } + } + + updated(c) { + super.updated(c); + + // TODO: Interaction state knowledge should be moved to FormControl... + ['touched', 'dirty', 'submitted', 'prefilled'].forEach(iState => { + if (c.has(iState)) { + this._renderFeedback(); + } + }); + } + + /** + * @typedef {object} FeedbackMessage + * @property {string} message this + * @property {string} type will be 'error' for messages from default Validators. Could be + * 'warning', 'info' etc. for Validators with custom types. Needed as a directive for + * feedbackNode how to render a message of a certain type + * @property {Validator} [validator] when the message is directly coupled to a Validator + * (in most cases), this property is filled. When a message is not coupled to a Validator + * (in case of success feedback which is based on a diff or current and previous validation + * results), this property can be left empty. + */ + + /** + * @param {Validator[]} validators list of objects having a .getMessage method + * @return {FeedbackMessage[]} + */ + async __getFeedbackMessages(validators) { + let fieldName = await this.fieldName; + return Promise.all( + validators.map(async validator => { + if (validator.config.fieldName) { + fieldName = await validator.config.fieldName; + } + const message = await validator._getMessage({ + validatorParams: validator.param, + modelValue: this.modelValue, + formControl: this, + fieldName, + }); + return { message, type: validator.type, validator }; + }), + ); + } + + /** + * @desc Responsible for retrieving messages from Validators and + * (delegation of) rendering them. + * + * For `._feedbackNode` (extension of LionValidationFeedback): + * - retrieve messages from highest prio Validators + * - provide the result to custom feedback node and let the + * custom node decide on their renderings + * + * In both cases: + * - we compute the 'show' flag (like 'hasErrorVisible') for all types + * - we set the customValidity message of the highest prio Validator + * - we set aria-invalid="true" in case hasErrorVisible is true + */ + async _renderFeedback() { + let feedbackCompleteResolve; + this.feedbackComplete = new Promise(resolve => { + feedbackCompleteResolve = resolve; + }); + + /** @type {Validator[]} */ + this.__prioritizedResult = this._prioritizeAndFilterFeedback({ + validationResult: this.__validationResult, + }); + + const messageMap = await this.__getFeedbackMessages(this.__prioritizedResult); + + this._feedbackNode.feedbackData = messageMap.length ? messageMap : undefined; + this.__storeTypeVisibilityOnInstance(this.__prioritizedResult); + feedbackCompleteResolve(); + } + + __storeTypeVisibilityOnInstance(prioritizedValidators) { + const result = {}; + this.__validatorTypeHistoryCache.forEach(previouslyStoredType => { + result[`has${pascalCase(previouslyStoredType)}Visible`] = false; + }); + + prioritizedValidators.forEach(v => { + result[`has${pascalCase(v.type)}Visible`] = true; + }); + + Object.assign(this, result); + } + + /** + * @overridable + * @desc Orders all active validators in this.__validationResult. Can + * also filter out occurrences (based on interaction states) + * @returns {Validator[]} ordered list of Validators with feedback messages visible to the + * end user + */ + _prioritizeAndFilterFeedback({ validationResult }) { + const types = this.constructor.validationTypes; + // Sort all validators based on the type provided. + const res = validationResult.sort((a, b) => types.indexOf(a.type) - types.indexOf(b.type)); + return res.slice(0, this._visibleMessagesAmount); + } + + __handleA11yErrorVisible() { + // Screen reader output should be in sync with visibility of error messages + if (this._inputNode) { + this._inputNode.setAttribute('aria-invalid', this.hasErrorVisible); + // this._inputNode.setCustomValidity(this._validationMessage || ''); + } + } + }, +); diff --git a/packages/validate/src/LionValidationFeedback.js b/packages/validate/src/LionValidationFeedback.js new file mode 100644 index 000000000..7aa181cae --- /dev/null +++ b/packages/validate/src/LionValidationFeedback.js @@ -0,0 +1,35 @@ +import { html, LitElement } from '@lion/core'; + +/** + * @desc Takes care of accessible rendering of error messages + * Should be used in conjunction with FormControl having ValidateMixin applied + */ +export class LionValidationFeedback extends LitElement { + static get properties() { + return { + /** + * @property {FeedbackData} feedbackData + */ + feedbackData: Array, + }; + } + + /** + * @overridable + */ + // eslint-disable-next-line class-methods-use-this + _messageTemplate({ message }) { + return message; + } + + render() { + return html` + ${this.feedbackData && + this.feedbackData.map( + ({ message, type, validator }) => html` + ${this._messageTemplate({ message, type, validator })} + `, + )} + `; + } +} diff --git a/packages/validate/src/ResultValidator.js b/packages/validate/src/ResultValidator.js new file mode 100644 index 000000000..61249d1af --- /dev/null +++ b/packages/validate/src/ResultValidator.js @@ -0,0 +1,18 @@ +import { Validator } from './Validator.js'; + +/** + * @desc Instead of evaluating the result of a regular validator, a HolisticValidator looks + * at the total result of regular Validators. Instead of an execute function, it uses a + * 'executeOnResults' Validator. + * ResultValidators cannot be async, and should noy contain an execute method. + */ +export class ResultValidator extends Validator { + /** + * @param {object} context + * @param {Validator[]} context.validationResult + * @param {Validator[]} context.prevValidationResult + * @param {Validator[]} context.validators + * @returns {Feedback[]} + */ + executeOnResults({ validationResult, prevValidationResult, validators }) {} // eslint-disable-line +} diff --git a/packages/validate/src/ValidateMixin.js b/packages/validate/src/ValidateMixin.js index 7058a948d..8845648c6 100644 --- a/packages/validate/src/ValidateMixin.js +++ b/packages/validate/src/ValidateMixin.js @@ -1,662 +1,399 @@ -/* eslint-disable class-methods-use-this, camelcase, no-param-reassign */ +/* eslint-disable class-methods-use-this, camelcase, no-param-reassign, max-classes-per-file */ -import { dedupeMixin, SlotMixin } from '@lion/core'; -import { localize, LocalizeMixin } from '@lion/localize'; +import { dedupeMixin } from '@lion/core'; import { Unparseable } from './Unparseable.js'; -import { randomOk } from './validators.js'; - -// TODO: extract from module like import { pascalCase } from 'lion-element/CaseMapUtils.js' -const pascalCase = str => str.charAt(0).toUpperCase() + str.slice(1); - -/* @polymerMixin */ +import { pascalCase } from './utils/pascal-case.js'; +import { Required } from './validators/Required.js'; +import { ResultValidator } from './ResultValidator.js'; +import { SyncUpdatableMixin } from './utils/SyncUpdatableMixin.js'; +/** + * @desc Handles all validation, based on modelValue changes. It has no knowledge about dom and + * UI. All error visibility, dom interaction and accessibility are handled in FeedbackMixin. + * + * @event error-state-changed fires when FormControl goes from non-error to error state and vice versa + * @event error-changed fires when the Validator(s) leading to the error state, change + */ export const ValidateMixin = dedupeMixin( superclass => - // eslint-disable-next-line no-unused-vars, no-shadow, max-len - class ValidateMixin extends LocalizeMixin(SlotMixin(superclass)) { - /* * * * * * * * * * - Configuration */ - - constructor() { - super(); - this.__oldValues = {}; - } - - get slots() { - return { - ...super.slots, - feedback: () => document.createElement('div'), - }; - } - - static get localizeNamespaces() { - return [ - { - /* FIXME: This awful switch statement is used to make sure it works with polymer build.. */ - 'lion-validate': locale => { - switch (locale) { - case 'bg-BG': - return import('../translations/bg-BG.js'); - case 'bg': - return import('../translations/bg.js'); - case 'cs-CZ': - return import('../translations/cs-CZ.js'); - case 'cs': - return import('../translations/cs.js'); - case 'de-DE': - return import('../translations/de-DE.js'); - case 'de': - return import('../translations/de.js'); - case 'en-AU': - return import('../translations/en-AU.js'); - case 'en-GB': - return import('../translations/en-GB.js'); - case 'en-US': - return import('../translations/en-US.js'); - case 'en-PH': - case 'en': - return import('../translations/en.js'); - case 'es-ES': - return import('../translations/es-ES.js'); - case 'es': - return import('../translations/es.js'); - case 'fr-FR': - return import('../translations/fr-FR.js'); - case 'fr-BE': - return import('../translations/fr-BE.js'); - case 'fr': - return import('../translations/fr.js'); - case 'hu-HU': - return import('../translations/hu-HU.js'); - case 'hu': - return import('../translations/hu.js'); - case 'it-IT': - return import('../translations/it-IT.js'); - case 'it': - return import('../translations/it.js'); - case 'nl-BE': - return import('../translations/nl-BE.js'); - case 'nl-NL': - return import('../translations/nl-NL.js'); - case 'nl': - return import('../translations/nl.js'); - case 'pl-PL': - return import('../translations/pl-PL.js'); - case 'pl': - return import('../translations/pl.js'); - case 'ro-RO': - return import('../translations/ro-RO.js'); - case 'ro': - return import('../translations/ro.js'); - case 'ru-RU': - return import('../translations/ru-RU.js'); - case 'ru': - return import('../translations/ru.js'); - case 'sk-SK': - return import('../translations/sk-SK.js'); - case 'sk': - return import('../translations/sk.js'); - case 'uk-UA': - return import('../translations/uk-UA.js'); - case 'uk': - return import('../translations/uk.js'); - case 'zh-CN': - case 'zh': - return import('../translations/zh.js'); - default: - return import(`../translations/${locale}.js`); - } - }, - }, - ...super.localizeNamespaces, - ]; - } - + // eslint-disable-next-line no-unused-vars, no-shadow + class ValidateMixin extends SyncUpdatableMixin(superclass) { static get properties() { return { /** - * List of validators that should set the input to invalid + * @desc List of all Validator instances applied to FormControl + * @type {Validator[]} + * @example + * FormControl.validators = [new Required(), new MinLength(3, { type: 'warning' })]; */ - errorValidators: { - type: Array, - }, - error: { - type: Object, - }, - errorState: { - type: Boolean, - attribute: 'error-state', - reflect: true, - }, - errorShow: { - type: Boolean, - attribute: 'error-show', - reflect: true, - }, - warningValidators: { - type: Object, - }, - warning: { - type: Object, - }, - warningState: { - type: Boolean, - attribute: 'warning-state', - reflect: true, - }, - warningShow: { - type: Boolean, - attribute: 'warning-show', - reflect: true, - }, - infoValidators: { - type: Object, - }, - info: { - type: Object, - }, - infoState: { - type: Boolean, - attribute: 'info-state', - reflect: true, - }, - infoShow: { - type: Boolean, - attribute: 'info-show', - reflect: true, - }, - successValidators: { - type: Object, - }, - success: { - type: Object, - }, - successState: { - type: Boolean, - attribute: 'success-state', - reflect: true, - }, - successShow: { - type: Boolean, - attribute: 'success-show', - reflect: true, - }, - invalid: { - type: Boolean, - reflect: true, - }, - message: { - type: Boolean, - }, - defaultSuccessFeedback: { - type: Boolean, - }, + validators: Array, + /** - * The currently displayed message(s) + * @desc Readonly validity states for all Validators of type 'error' + * @type {ValidityStatesObject} + * @example + * FormControl.errorStates; // => { required: true, minLength: false } + * FormControl.errorStates.required; // => true */ - _validationMessage: { - type: String, + errorStates: { + type: Object, + hasChanged: this._hasObjectChanged, }, + + /** + * @desc Readonly state for the error type. When at least one Validator of + * type 'error' is active (for instance required in case of an empty field), + * this Boolean flag will be true. + * For styling purposes, this state is reflected to an attribute + * @type {boolean} + * @example + * FormControl.hasError; // => true + */ + hasError: { + type: Boolean, + attribute: 'has-error', + reflect: true, + }, + + /** + * @desc flag that indicates whether async validation is pending + */ + isPending: { + type: Boolean, + attribute: 'is-pending', + reflect: true, + }, + + /** + * @desc value that al validation revolves around: once changed (usually triggered by + * end user entering input), it will automatically trigger validation. + */ + modelValue: Object, + + /** + * @desc specialized fields (think of input-date and input-email) can have preconfigured + * validators. + */ + defaultValidators: Array, }; } - updated(changedProperties) { - super.updated(changedProperties); - - if ( - [ - 'error', - 'warning', - 'info', - 'success', - 'touched', - 'dirty', - 'submitted', - 'prefilled', - 'label', - ].some(key => changedProperties.has(key)) - ) { - this._createMessageAndRenderFeedback(); - } - - if (changedProperties.has('errorShow')) { - this._onErrorShowChangedAsync(); - } + /** + * @overridable + */ + static get validationTypes() { + return ['error']; } - _requestUpdate(name, oldVal) { - super._requestUpdate(name, oldVal); + get _allValidators() { + return [...this.validators, ...this.defaultValidators]; + } + + constructor() { + super(); + + this.isPending = false; + /** @type {Validator[]} */ + this.validators = []; + /** @type {Validator[]} */ + this.defaultValidators = []; + + /** @type {Validator[]} */ + this.__syncValidationResult = []; + + /** @type {Validator[]} */ + this.__asyncValidationResult = []; /** - * Validation needs to happen before other updates - * E.g. formatting should not happen before we know the updated errorState + * @desc contains results from sync Validators, async Validators and ResultValidators + * @type {Validator[]} */ - if ( - [ - 'errorValidators', - 'warningValidators', - 'infoValidators', - 'successValidators', - 'modelValue', - ].some(key => name === key) - ) { + this.__validationResult = []; + + /** + * Stores all types that have been validated. Needed for clearing + * previously stored states on the instance + */ + this.__validatorTypeHistoryCache = new Set(); + this.constructor.validationTypes.forEach(t => this.__validatorTypeHistoryCache.add(t)); + + this.__onValidatorUpdated = this.__onValidatorUpdated.bind(this); + } + + firstUpdated(c) { + super.firstUpdated(c); + this.__validateInitialized = true; + this.validate(); + } + + updateSync(name, oldValue) { + super.updateSync(name, oldValue); + if (name === 'validators') { + // trigger validation (ideally only for the new or changed validator) + this.__setupValidators(); this.validate(); + } else if (name === 'modelValue') { + this.validate({ clearCurrentResult: true }); } + } - // @deprecated adding css classes for backwards compatibility + updated(c) { + super.updated(c); this.constructor.validationTypes.forEach(type => { - if (name === `${type}State`) { - this.classList[this[`${type}State`] ? 'add' : 'remove'](`state-${type}`); - } - if (name === `${type}Show`) { - this.classList[this[`${type}Show`] ? 'add' : 'remove'](`state-${type}-show`); - } - }); - if (name === 'invalid') { - this.classList[this.invalid ? 'add' : 'remove'](`state-invalid`); - } - - if (name === 'error' && this.error !== oldVal) { - this._onErrorChanged(); - } - - if (name === 'warning' && this.warning !== oldVal) { - this._onWarningChanged(); - } - - if (name === 'info' && this.info !== oldVal) { - this._onInfoChanged(); - } - - if (name === 'success' && this.success !== oldVal) { - this._onSuccessChanged(); - } - - if (name === 'errorState' && this.errorState !== oldVal) { - this._onErrorStateChanged(); - } - - if (name === 'warningState' && this.warningState !== oldVal) { - this._onWarningStateChanged(); - } - - if (name === 'infoState' && this.infoState !== oldVal) { - this._onInfoStateChanged(); - } - - if (name === 'successState' && this.successState !== oldVal) { - this._onSuccessStateChanged(); - } - } - - static get validationTypes() { - return ['error', 'warning', 'info', 'success']; - } - - get _feedbackElement() { - return Array.from(this.children).find(child => child.slot === 'feedback'); - } - - getFieldName(validatorParams) { - const labelEl = Array.from(this.children).find(child => child.slot === 'label'); - const label = this.label || (labelEl && labelEl.textContent); - - if (validatorParams && validatorParams.fieldName) { - return validatorParams.fieldName; - } - if (label) { - return label; - } - return this.name; - } - - _onErrorStateChanged() { - this.dispatchEvent( - new CustomEvent('error-state-changed', { bubbles: true, composed: true }), - ); - } - - _onWarningStateChanged() { - this.dispatchEvent( - new CustomEvent('warning-state-changed', { bubbles: true, composed: true }), - ); - } - - _onInfoStateChanged() { - this.dispatchEvent( - new CustomEvent('info-state-changed', { bubbles: true, composed: true }), - ); - } - - _onSuccessStateChanged() { - this.dispatchEvent( - new CustomEvent('success-state-changed', { bubbles: true, composed: true }), - ); - } - - /* * * * * * * * * * * * - Observer Handlers */ - - onLocaleUpdated() { - if (super.onLocaleUpdated) { - super.onLocaleUpdated(); - } - this._createMessageAndRenderFeedback(); - } - - _createMessageAndRenderFeedback() { - this._createMessage(); - const details = {}; - - this.constructor.validationTypes.forEach(type => { - details[type] = this[type]; - }); - - if (this._feedbackElement) { - // Only write to light DOM not put there by Application Developer, but by - if (typeof this._feedbackElement.renderFeedback === 'function') { - this._feedbackElement.renderFeedback(this.getValidationStates(), this.message, details); - } else { - this.renderFeedback(this.getValidationStates(), this.message, details); - } - } - } - - _onErrorChanged() { - if (!this.constructor._objectEquals(this.error, this.__oldValues.error)) { - this.dispatchEvent(new CustomEvent('error-changed', { bubbles: true, composed: true })); - } - } - - _onWarningChanged() { - if (!this.constructor._objectEquals(this.warning, this.__oldValues.warning)) { - this.dispatchEvent(new CustomEvent('warning-changed', { bubbles: true, composed: true })); - } - } - - _onInfoChanged() { - if (!this.constructor._objectEquals(this.info, this.__oldValues.info)) { - this.dispatchEvent(new CustomEvent('info-changed', { bubbles: true, composed: true })); - } - } - - _onSuccessChanged() { - if (!this.constructor._objectEquals(this.success, this.__oldValues.success)) { - this.dispatchEvent(new CustomEvent('success-changed', { bubbles: true, composed: true })); - } - } - - _createMessage() { - const newStates = this.getValidationStates(); - this.message = { list: [], message: '' }; - this.constructor.validationTypes.forEach(type => { - if (this[`show${pascalCase(type)}Condition`](newStates, this.__oldValidationStates)) { - this[`${type}Show`] = true; - this.message.list.push(...this[type].list); - } else { - this[`${type}Show`] = false; - } - }); - if (this.message.list.length > 0) { - this.messageState = true; - const { translationKeys, data } = this.message.list[0]; - data.fieldName = this.getFieldName(data.validatorParams); - this._validationMessage = this.translateMessage(translationKeys, data); - this.message.message = this._validationMessage; - } else { - this.messageState = false; - this._validationMessage = ''; - this.message.message = this._validationMessage; - } - return this.message.message; - } - - /** - * Can be overridden by sub classers - * Note that it's important to always render your feedback to the _feedbackElement textContent! - * This is necessary because it is allocated as the feedback slot, which is what the mixin renders feedback to. - */ - renderFeedback() { - if (this._feedbackElement) { - this._feedbackElement.textContent = this._validationMessage; - } - } - - _onErrorShowChangedAsync() { - // Screen reader output should be in sync with visibility of error messages - if (this._inputNode) { - this._inputNode.setAttribute('aria-invalid', this.errorShow); - // TODO: test and see if needed for a11y - // this._inputNode.setCustomValidity(this._validationMessage || ''); - } - } - - /* * * * * * * * * * - Public Methods */ - - getValidationStates() { - const result = {}; - this.constructor.validationTypes.forEach(type => { - result[type] = this[`${type}State`]; - }); - return result; - } - - /** - * Order is: Error, Warning, Info - * Transition from Error to "nothing" results in success - * Other transitions (from Warning/Info) are not followed by a success message - */ - validate() { - if (this.modelValue === undefined) { - this.__resetValidationStates(); - return; - } - this.__oldValidationStates = this.getValidationStates(); - this.constructor.validationTypes.forEach(type => { - this.validateType(type); - }); - this.dispatchEvent(new CustomEvent('validation-done', { bubbles: true, composed: true })); - } - - __resetValidationStates() { - this.constructor.validationTypes.forEach(type => { - this[`${type}State`] = false; - this[type] = {}; - }); - } - - /** - * Override if needed - */ - translateMessage(keys, data) { - return localize.msg(keys, data); - } - - showErrorCondition(newStates) { - return newStates.error; - } - - showWarningCondition(newStates) { - return newStates.warning && !newStates.error; - } - - showInfoCondition(newStates) { - return newStates.info && !newStates.error && !newStates.warning; - } - - showSuccessCondition(newStates, oldStates) { - return ( - newStates.success && - !newStates.error && - !newStates.warning && - !newStates.info && - oldStates.error - ); - } - - getErrorTranslationsKeys(data) { - return this.constructor.__getLocalizeKeys( - `error.${data.validatorName}`, - data.validatorName, - ); - } - - getWarningTranslationsKeys(data) { - return this.constructor.__getLocalizeKeys( - `warning.${data.validatorName}`, - data.validatorName, - ); - } - - getInfoTranslationsKeys(data) { - return this.constructor.__getLocalizeKeys(`info.${data.validatorName}`, data.validatorName); - } - - /** - * Special case for ok validators starting with 'random'. Example for randomOk: - * - will fetch translation for randomOk (should contain multiple translations keys) - * - split by ',' and then use one of those keys - * - will remember last random choice so it does not change on key stroke - * - remembering can be reset with this.__lastGetSuccessResult = false; - */ - getSuccessTranslationsKeys(data) { - let key = `success.${data.validatorName}`; - if (this.__lastGetSuccessResult && data.validatorName.indexOf('random') === 0) { - return this.__lastGetSuccessResult; - } - if (data.validatorName.indexOf('random') === 0) { - const getKeys = this.constructor.__getLocalizeKeys(key, data.validatorName); - const keysToConsider = this.translateMessage(getKeys); // eslint-disable-line max-len - if (keysToConsider) { - const randomKeys = keysToConsider.split(','); - key = randomKeys[Math.floor(Math.random() * randomKeys.length)].trim(); - } - } - const result = this.constructor.__getLocalizeKeys(key, data.validatorName); - this.__lastGetSuccessResult = result; - return result; - } - - /** - * Returns all the translation paths in right priority order - * - * @param {string} key usually `${type}.${validatorName}` - * @param {string} validatorName for which to create the keys - */ - static __getLocalizeKeys(key, validatorName) { - const result = []; - this.localizeNamespaces.forEach(ns => { - const namespace = typeof ns === 'object' ? Object.keys(ns)[0] : ns; - result.push(`${namespace}+${validatorName}:${key}`); - result.push(`${namespace}:${key}`); - }); - return result; - } - - /** - * type can be 'error', 'warning', 'info', 'success' - * - * a Validator can be - * - special string - * 'required' - * - function e.g - * MyValidate.isEmail, isCat, ... - * - array for parameters e.g. - * [minMaxLength, {min: 10, max: 15}], - * [minLength, {min: 5}], - * [contains, 'thisString'] - */ - validateType(type) { - const validators = this.getValidatorsForType(type); - if (!(validators && Array.isArray(validators) && validators.length > 0)) return; - - const resultList = []; - let value = this.modelValue; // This will end up being modelValue or Unparseable.viewValue - - for (let i = 0; i < validators.length; i += 1) { - const validatorArray = Array.isArray(validators[i]) ? validators[i] : [validators[i]]; - let validatorFn = validatorArray[0]; - const validatorParams = validatorArray[1]; - const validatorConfig = validatorArray[2]; - - let isRequiredValidator = false; // Whether the current is the required validator - if (typeof validatorFn === 'string' && validatorFn === 'required' && this.__isRequired) { - validatorFn = this.__isRequired; - isRequiredValidator = true; - } - - // When the modelValue can't be created, still allow all validators to give valuable - // feedbback to the user based on the current viewValue. - if (value instanceof Unparseable) { - value = value.viewValue; - } - - // We don't validate empty values, unless its 'required' - const shouldValidate = isRequiredValidator || !this.constructor.__isEmpty(value); - - if (typeof validatorFn === 'function') { - if (shouldValidate) { - const result = validatorFn(value, validatorParams); - // eslint-disable-next-line no-restricted-syntax - for (const validatorName in result) { - if (!result[validatorName]) { - const data = { - validatorName, - validatorParams, - validatorConfig, - validatorType: type, - name: this.name, - value: this.modelValue, - }; - resultList.push({ - data, - translationKeys: this[`get${pascalCase(type)}TranslationsKeys`](data), - }); - } - } - } - } else { - console.warn('That does not look like a validator function', validatorFn); // eslint-disable-line - // eslint-disable-next-line - console.warn( - // eslint-disable-next-line - 'You should provide options like so errorValidators=${[[functionName, {min: 5, max: 10}]]}', + if (c.has(`${type}States`)) { + this.dispatchEvent( + new Event(`${type}-states-changed`, { bubbles: true, composed: true }), ); } + + if (c.has(`has${pascalCase(type)}`)) { + this.dispatchEvent(new Event(`has-${type}-changed`, { bubbles: true, composed: true })); + } + }); + } + + /** + * @desc The main function of this mixin. Triggered by: + * - a modelValue change + * - a change in the 'validators' array + * - a change in the config of an individual Validator + * + * Three situations are handled: + * - A.1 The FormControl is empty: further execution is halted. When the Required Validator + * (being mutually exclusive to the other Validators) is applied, it will end up in the + * validation result (as the only Validator, since further execution was halted). + * - A.2 There are synchronous Validators: this is the most common flow. When modelValue hasn't + * changed since last async results were generated, 'sync results' are merged with the + * 'async results'. + * - A.3 There are asynchronous Validators: for instance when server side evaluation is needed. + * Executions are scheduled and awaited and the 'async results' are merged with the + * 'sync results'. + * + * - B. There are ResultValidators. After steps A.1, A.2, or A.3 are finished, the holistic + * ResultValidators (evaluating the total result of the 'regular' (A.1, A.2 and A.3) validators) + * will be run... + * + * Situations A.2 and A.3 are not mutually exclusive and can be triggered within one validate() + * call. Situation B will occur after every call. + */ + async validate({ clearCurrentResult } = {}) { + if (!this.__validateInitialized) { + return; } - let result = {}; - if (resultList.length > 0) { - result = { - list: resultList, // TODO: maybe call this details? - }; - // will have a reference to lion-field by name, so user can do: - // formName.fieldName.errors.validatorName - resultList.forEach(resultListElement => { - result[resultListElement.data.validatorName] = true; + this.__storePrevResult(); + if (clearCurrentResult) { + // Clear ('invalidate') all pending and existing validation results. + // This is needed because we have async (pending) validators whose results + // need to be merged with those of sync validators and vice versa. + this.__clearValidationResults(); + } + await this.__executeValidators(); + } + + __storePrevResult() { + this.__prevValidationResult = this.__validationResult; + } + + /** + * @desc step A1-3 + B (as explained in 'validate') + */ + async __executeValidators() { + this.validateComplete = new Promise(resolve => { + this.__validateCompleteResolve = resolve; + }); + + // When the modelValue can't be created by FormatMixin.parser, still allow all validators + // to give valuable feedback to the user based on the current viewValue. + const value = + this.modelValue instanceof Unparseable ? this.modelValue.viewValue : this.modelValue; + + /** @type {Validator} */ + const requiredValidator = this._allValidators.find(v => v instanceof Required); + + /** + * 1. Handle the 'exceptional' Required validator: + * - the validatity is dependent on the formControl type and therefore determined + * by the formControl.__isEmpty method. Basically, the Required Validator is a means + * to trigger formControl.__isEmpty. + * - when __isEmpty returns false, the input was empty. This means we need to stop + * validation here, because all other Validators' execute functions assume the + * value is not empty (there would be nothing to validate). + */ + const isEmpty = this.__isEmpty(value); + if (isEmpty) { + if (requiredValidator) { + this.__syncValidationResult = [requiredValidator]; + } + this.__finishValidation({ source: 'sync' }); + return; + } + + // Separate Validators in sync and async + const /** @type {Validator[]} */ filteredValidators = this._allValidators.filter( + v => !(v instanceof ResultValidator) && !(v instanceof Required), + ); + const /** @type {Validator[]} */ syncValidators = filteredValidators.filter(v => !v.async); + const /** @type {Validator[]} */ asyncValidators = filteredValidators.filter(v => v.async); + + /** + * 2. Synchronous validators + */ + this.__executeSyncValidators(syncValidators, value, { + hasAsync: Boolean(asyncValidators.length), + }); + + /** + * 3. Asynchronous validators + */ + await this.__executeAsyncValidators(asyncValidators, value); + } + + /** + * @desc step A2, calls __finishValidation + * @param {Validator[]} syncValidators + */ + __executeSyncValidators(syncValidators, value, { hasAsync }) { + if (syncValidators.length) { + this.__syncValidationResult = syncValidators.filter(v => v.execute(value, v.param)); + } + this.__finishValidation({ source: 'sync', hasAsync }); + } + + /** + * @desc step A3, calls __finishValidation + * @param {Validator[]} filteredValidators all Validators except required and ResultValidators + */ + async __executeAsyncValidators(asyncValidators, value) { + if (asyncValidators.length) { + this.isPending = true; + const resultPromises = asyncValidators.map(v => v.execute(value, v.param)); + const booleanResults = await Promise.all(resultPromises); + this.__asyncValidationResult = booleanResults + .map((r, i) => asyncValidators[i]) // Create an array of Validators + .filter((v, i) => booleanResults[i]); // Only leave the ones returning true + this.__finishValidation({ source: 'async' }); + this.isPending = false; + } + } + + /** + * @desc step B, called by __finishValidation + * @param {Validator[]} regularValidationResult result of steps 1-3 + */ + __executeResultValidators(regularValidationResult) { + /** @type {ResultValidator[]} */ + const resultValidators = this._allValidators.filter( + v => !v.async && v instanceof ResultValidator, + ); + + return resultValidators.filter(v => + v.executeOnResults({ + regularValidationResult, + prevValidationResult: this.__prevValidationResult, + }), + ); + } + + /** + * @param {object} options + * @param {'sync'|'async'} options.source + * @param {boolean} [options.hasAsync] whether async validators are configured in this run. + * If not, we have nothing left to wait for. + */ + __finishValidation({ source, hasAsync }) { + const /** @type {Validator[]} */ syncAndAsyncOutcome = [ + ...this.__syncValidationResult, + ...this.__asyncValidationResult, + ]; + // if we have any ResultValidators left, now is the time to run them... + const resultOutCome = this.__executeResultValidators(syncAndAsyncOutcome); + + /** @typedef {Validator[]} TotalValidationResult */ + this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome]; + this._storeResultsOnInstance(this.__validationResult); + + /** private event that should be listened to by FeedbackMixin / LionFieldSet */ + this.dispatchEvent(new Event('validate-performed', { bubbles: true, composed: true })); + if (source === 'async' || !hasAsync) { + this.__validateCompleteResolve(); + } + } + + /** + * @desc For all results, for all types, stores results on instance. + * For errors, this means: + * - this.hasError = true/false; + * - this.errorStates = { + * [validatorName1]: true, + * [validatorName2]: true, + * } + * Note that 'this.hasErrorVisible' won't be set here: it will be based on the outcome of + * method `._proritizeAndFilterFeedback`. + * @param {Validator[]} valResult + */ + _storeResultsOnInstance(valResult) { + const instanceResult = {}; + this.__resetInstanceValidationStates(instanceResult); + + valResult.forEach(validator => { + // By default, this will be reflected to attr 'error-state' in case of + // 'error' type. Subclassers supporting different types need to + // configure attribute reflection themselves. + instanceResult[`has${pascalCase(validator.type)}`] = true; + instanceResult[`${validator.type}States`] = + instanceResult[`${validator.type}States`] || {}; + instanceResult[`${validator.type}States`][validator.name] = true; + this.__validatorTypeHistoryCache.add(validator.type); + }); + Object.assign(this, instanceResult); + } + + __resetInstanceValidationStates(instanceResult) { + this.__validatorTypeHistoryCache.forEach(previouslyStoredType => { + instanceResult[`has${pascalCase(previouslyStoredType)}`] = false; + instanceResult[`${previouslyStoredType}States`] = {}; + }); + } + + __clearValidationResults() { + this.__syncValidationResult = []; + this.__asyncValidationResult = []; + } + + __onValidatorUpdated(e) { + if (e.type === 'param-changed' || e.type === 'config-changed') { + this.validate(); + } + } + + __setupValidators() { + const events = ['param-changed', 'config-changed']; + if (this.__prevValidators) { + this.__prevValidators.forEach(v => { + events.forEach(e => v.removeEventListener(e, this.__onValidatorUpdated)); + v.onFormControlDisconnect(this); }); } - - this[`${type}State`] = resultList.length > 0; - this.__oldValues[type] = this[type]; - this[type] = result; + this._allValidators.forEach(v => { + events.forEach(e => v.addEventListener(e, this.__onValidatorUpdated)); + v.onFormControlConnect(this); + }); + this.__prevValidators = this._allValidators; } - getValidatorsForType(type) { - if (this.defaultSuccessFeedback && type === 'success') { - return [[randomOk]].concat(this.successValidators || []); + static _hasObjectChanged(result, prevResult) { + return JSON.stringify(result) !== JSON.stringify(prevResult); + } + + __isEmpty(v) { + if (typeof this._isEmpty === 'function') { + return this._isEmpty(v); } - return this[`${type}Validators`] || []; - } - - static _objectEquals(result, prevResult) { - if (!prevResult) return false; - return Object.keys(result).join('') === Object.keys(prevResult).join(''); - } - - // When empty (model)value, - static __isEmpty(v) { + // // TODO: move to compat layer. Be sure to keep this, because people use this a lot + // if (typeof this.__isRequired === 'function') { + // return !this.__isRequired(v); + // } return v === null || typeof v === 'undefined' || v === ''; } }, diff --git a/packages/validate/src/Validator.js b/packages/validate/src/Validator.js new file mode 100644 index 000000000..12c3cb350 --- /dev/null +++ b/packages/validate/src/Validator.js @@ -0,0 +1,92 @@ +import { fakeExtendsEventTarget } from './utils/fake-extends-event-target.js'; + +export class Validator { + constructor(param, config) { + fakeExtendsEventTarget(this); + + this.name = ''; + this.async = false; + this.__param = param; + this.__config = config || {}; + this.type = (config && config.type) || 'error'; // Default type supported by ValidateMixin + } + + /** + * @desc The function that returns a Boolean + * @param {string|Date|Number|object} modelValue + * @param {object} param + * @returns {Boolean|Promise} + */ + execute(modelValue, param) {} // eslint-disable-line + + set param(p) { + this.__param = p; + this.dispatchEvent(new Event('param-changed')); + } + + get param() { + return this.__param; + } + + set config(c) { + this.__config = c; + this.dispatchEvent(new Event('config-changed')); + } + + get config() { + return this.__config; + } + + /** + * @overridable + * @param {object} data + * @param {*} data.modelValue + * @param {string} data.fieldName + * @param {*} data.validatorParams + * @returns {string|Node|Promise|() => stringOrNode)} + */ + async _getMessage(data) { + if (typeof this.config.getMessage === 'function') { + return this.config.getMessage(data); + } + return this.constructor.getMessage(data); + } + + /** + * @overridable + * @param {object} data + * @param {*} data.modelValue + * @param {string} data.fieldName + * @param {*} data.validatorParams + * @returns {string|Node|Promise|() => stringOrNode)} + */ + static async getMessage(data) {} // eslint-disable-line no-unused-vars, no-empty-function + + /** + * @param {FormControl} formControl + */ + onFormControlConnect(formControl) {} // eslint-disable-line + + /** + * @param {FormControl} formControl + */ + onFormControlDisconnect(formControl) {} // eslint-disable-line + + /** + * @desc Used on async Validators, makes it able to do perf optimizations when there are + * pending "execute" calls with outdated values. + * ValidateMixin calls Validator.abortExecution() an async Validator can act accordingly, + * depending on its implementation of the "execute" function. + * - For instance, when fetch was called: + * https://stackoverflow.com/questions/31061838/how-do-i-cancel-an-http-fetch-request + * - Or, when a webworker was started, its process could be aborted and then restarted. + */ + abortExecution() {} // eslint-disable-line +} + +// For simplicity, a default validator only handles one state: +// it can either be true or false an it will only have one message. +// In more advanced cases (think of the feedback mechanism for the maximum number of +// characters in Twitter), more states are needed. The alternative of +// having multiple distinct validators would be cumbersome to create and maintain, +// also because the validations would tie too much into each others logic. diff --git a/packages/validate/src/isValidatorApplied.js b/packages/validate/src/isValidatorApplied.js deleted file mode 100644 index 326fa5e18..000000000 --- a/packages/validate/src/isValidatorApplied.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * TODO: refactor validators to classes, putting needed meta info on instance. - * Note that direct function comparison (Validator[0] === minDate) doesn't work when code - * is transpiled - * @param {String} name - a name like minDate, maxDate, minMaxDate - * @param {Function} fn - the validator function to execute provided in [fn, param, config] - * @param {Function} requiredSignature - arguments needed to execute fn without failing - * @returns {Boolean} - whether the validator (name) is applied - */ -export function isValidatorApplied(name, fn, requiredSignature) { - let result; - try { - result = Object.keys(fn(new Date(), requiredSignature))[0] === name; - } catch (e) { - result = false; - } - return result; -} diff --git a/packages/validate/src/loadDefaultFeedbackMessages.js b/packages/validate/src/loadDefaultFeedbackMessages.js new file mode 100644 index 000000000..6756810b8 --- /dev/null +++ b/packages/validate/src/loadDefaultFeedbackMessages.js @@ -0,0 +1,112 @@ +import { localize } from '@lion/localize'; +import { Required } from './validators/Required.js'; +import { EqualsLength, MaxLength } from './validators/StringValidators.js'; +import { DefaultSuccess } from './resultValidators/DefaultSuccess.js'; + +let loaded = false; + +export function loadDefaultFeedbackMessages() { + if (loaded === true) { + return; + } + const validateNamespace = localize.loadNamespace({ + 'lion-validate': locale => { + switch (locale) { + case 'bg-BG': + return import('../translations/bg-BG.js'); + case 'bg': + return import('../translations/bg.js'); + case 'cs-CZ': + return import('../translations/cs-CZ.js'); + case 'cs': + return import('../translations/cs.js'); + case 'de-DE': + return import('../translations/de-DE.js'); + case 'de': + return import('../translations/de.js'); + case 'en-AU': + return import('../translations/en-AU.js'); + case 'en-GB': + return import('../translations/en-GB.js'); + case 'en-US': + return import('../translations/en-US.js'); + case 'en-PH': + case 'en': + return import('../translations/en.js'); + case 'es-ES': + return import('../translations/es-ES.js'); + case 'es': + return import('../translations/es.js'); + case 'fr-FR': + return import('../translations/fr-FR.js'); + case 'fr-BE': + return import('../translations/fr-BE.js'); + case 'fr': + return import('../translations/fr.js'); + case 'hu-HU': + return import('../translations/hu-HU.js'); + case 'hu': + return import('../translations/hu.js'); + case 'it-IT': + return import('../translations/it-IT.js'); + case 'it': + return import('../translations/it.js'); + case 'nl-BE': + return import('../translations/nl-BE.js'); + case 'nl-NL': + return import('../translations/nl-NL.js'); + case 'nl': + return import('../translations/nl.js'); + case 'pl-PL': + return import('../translations/pl-PL.js'); + case 'pl': + return import('../translations/pl.js'); + case 'ro-RO': + return import('../translations/ro-RO.js'); + case 'ro': + return import('../translations/ro.js'); + case 'ru-RU': + return import('../translations/ru-RU.js'); + case 'ru': + return import('../translations/ru.js'); + case 'sk-SK': + return import('../translations/sk-SK.js'); + case 'sk': + return import('../translations/sk.js'); + case 'uk-UA': + return import('../translations/uk-UA.js'); + case 'uk': + return import('../translations/uk.js'); + case 'zh-CN': + case 'zh': + return import('../translations/zh.js'); + default: + return import(`../translations/${locale}.js`); + } + }, + }); + + Required.getMessage = async data => { + await validateNamespace; + return localize.msg('lion-validate:error.required', data); + }; + + EqualsLength.getMessage = async data => { + await validateNamespace; + return localize.msg('lion-validate:error.equalsLength', data); + }; + + MaxLength.getMessage = async data => { + await validateNamespace; + return localize.msg('lion-validate:error.maxLength', data); + }; + + DefaultSuccess.getMessage = async data => { + await validateNamespace; + const randomKeys = localize.msg('lion-validate:success.randomOk').split(','); + const key = randomKeys[Math.floor(Math.random() * randomKeys.length)].trim(); + return localize.msg(`lion-validate:${key}`, data); + }; + + loaded = true; +} diff --git a/packages/validate/src/resultValidators/DefaultSuccess.js b/packages/validate/src/resultValidators/DefaultSuccess.js new file mode 100644 index 000000000..411a074e6 --- /dev/null +++ b/packages/validate/src/resultValidators/DefaultSuccess.js @@ -0,0 +1,16 @@ +import { ResultValidator } from '../ResultValidator.js'; + +export class DefaultSuccess 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; + } +} diff --git a/packages/validate/src/utils/SyncUpdatableMixin.js b/packages/validate/src/utils/SyncUpdatableMixin.js new file mode 100644 index 000000000..b8f16485a --- /dev/null +++ b/packages/validate/src/utils/SyncUpdatableMixin.js @@ -0,0 +1,93 @@ +import { dedupeMixin } from '@lion/core'; + +// TODO: will be moved to @Lion/core later + +/** + * @desc Why this mixin? + * - it adheres to the "Member Order Independence" web components standard: + * https://github.com/webcomponents/gold-standard/wiki/Member-Order-Independence + * - sync observers can be dependent on the outcome of the render function (or, more generically + * speaking, the light and shadow dom). This aligns with the 'updated' callback that is supported + * out of the box by LitElement, which runs after connectedCallback as well. + * - makes the propertyAccessor.`hasChanged` compatible in synchronous updates: + * `updateSync` will only be called when new value differs from old value. + * See: https://lit-element.polymer-project.org/guide/lifecycle#haschanged + * - it is a stable abstaction on top of a protected/non offical lifecycle LitElement api. + * Whenever the implementation of `_requestUpdate` changes (this happened in the past for + * `requestUpdate`) we only have to change our abstraction instead of all our components + */ +export const SyncUpdatableMixin = dedupeMixin( + superclass => + class SyncUpdatable extends superclass { + constructor() { + super(); + // Namespace for this mixin that guarantees naming clashes will not occur... + this.__SyncUpdatableNamespace = {}; + } + + firstUpdated(c) { + super.firstUpdated(c); + this.__SyncUpdatableNamespace.connected = true; + this.__syncUpdatableInitialize(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.__SyncUpdatableNamespace.connected = false; + } + + /** + * Makes the propertyAccessor.`hasChanged` compatible in synchronous updates + * @param {string} name + * @param {*} oldValue + */ + static __syncUpdatableHasChanged(name, newValue, oldValue) { + const properties = this._classProperties; + if (properties.get(name) && properties.get(name).hasChanged) { + return properties.get(name).hasChanged(newValue, oldValue); + } + return newValue !== oldValue; + } + + __syncUpdatableInitialize() { + const ns = this.__SyncUpdatableNamespace; + const ctor = this.constructor; + + ns.initialized = true; + // Empty queue... + if (ns.queue) { + Array.from(ns.queue).forEach(name => { + if (ctor.__syncUpdatableHasChanged(name, this[name], undefined)) { + this.updateSync(name, undefined); + } + }); + } + } + + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + + this.__SyncUpdatableNamespace = this.__SyncUpdatableNamespace || {}; + const ns = this.__SyncUpdatableNamespace; + const ctor = this.constructor; + + // Before connectedCallback: queue + if (!ns.connected) { + ns.queue = ns.queue || new Set(); + // Makes sure that we only initialize one time, with most up to date value + ns.queue.add(name); + } // After connectedCallback: guarded proxy to updateSync + else if (ctor.__syncUpdatableHasChanged(name, this[name], oldValue)) { + this.updateSync(name, oldValue); + } + } + + /** + * @desc A public abstraction that has the exact same api as `_requestUpdate`. + * All code previously present in _requestUpdate can be placed in this method. + * @param {string} name + * @param {*} oldValue + */ + updateSync(name, oldValue) {} // eslint-disable-line class-methods-use-this, no-unused-vars + }, +); diff --git a/packages/validate/src/utils/fake-extends-event-target.js b/packages/validate/src/utils/fake-extends-event-target.js new file mode 100644 index 000000000..d74aa2c7c --- /dev/null +++ b/packages/validate/src/utils/fake-extends-event-target.js @@ -0,0 +1,8 @@ +// TODO: this method has to be removed when EventTarget polyfill is available on IE11 +export function fakeExtendsEventTarget(instance) { + const delegate = document.createDocumentFragment(); + ['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => { + // eslint-disable-next-line no-param-reassign + instance[funcName] = (...args) => delegate[funcName](...args); + }); +} diff --git a/packages/validate/src/utils/pascal-case.js b/packages/validate/src/utils/pascal-case.js new file mode 100644 index 000000000..1768413b9 --- /dev/null +++ b/packages/validate/src/utils/pascal-case.js @@ -0,0 +1,3 @@ +export function pascalCase(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/validate/src/validators/DateValidators.js b/packages/validate/src/validators/DateValidators.js new file mode 100644 index 000000000..f083d6582 --- /dev/null +++ b/packages/validate/src/validators/DateValidators.js @@ -0,0 +1,85 @@ +/* eslint-disable max-classes-per-file */ +import { normalizeDateTime } from '@lion/localize'; +import { Validator } from '../Validator.js'; + +function isDate(value) { + return ( + Object.prototype.toString.call(value) === '[object Date]' && !Number.isNaN(value.getTime()) + ); +} + +export class IsDate extends Validator { + constructor(...args) { + super(...args); + this.name = 'IsDate'; + } + + // eslint-disable-next-line class-methods-use-this + execute(value) { + let hasError = false; + if (!isDate(value)) { + hasError = true; + } + return hasError; + } +} + +export class MinDate extends Validator { + constructor(...args) { + super(...args); + this.name = 'MinDate'; + } + + execute(value, min = this.param) { + let hasError = false; + if (!isDate(value) || value < normalizeDateTime(min)) { + hasError = true; + } + return hasError; + } +} + +export class MaxDate extends Validator { + constructor(...args) { + super(...args); + this.name = 'MaxDate'; + } + + execute(value, max = this.param) { + let hasError = false; + if (!isDate(value) || value > normalizeDateTime(max)) { + hasError = true; + } + return hasError; + } +} + +export class MinMaxDate extends Validator { + constructor(...args) { + super(...args); + this.name = 'MinMaxDate'; + } + + execute(value, { min = 0, max = 0 } = this.param) { + let hasError = false; + if (!isDate(value) || value < normalizeDateTime(min) || value > normalizeDateTime(max)) { + hasError = true; + } + return hasError; + } +} + +export class IsDateDisabled extends Validator { + constructor(...args) { + super(...args); + this.name = 'IsDateDisabled'; + } + + execute(value, isDisabledFn = this.param) { + let hasError = false; + if (!isDate(value) || isDisabledFn(value)) { + hasError = true; + } + return hasError; + } +} diff --git a/packages/validate/src/validators/NumberValidators.js b/packages/validate/src/validators/NumberValidators.js new file mode 100644 index 000000000..ac2595bf1 --- /dev/null +++ b/packages/validate/src/validators/NumberValidators.js @@ -0,0 +1,72 @@ +/* eslint-disable max-classes-per-file */ +import { Validator } from '../Validator.js'; + +/** + * check for not being NaN (NaN is the only value in javascript which is not equal to itself) + * + * @param {number} value to check + */ +function isNumber(value) { + return value === value && typeof value === 'number'; // eslint-disable-line no-self-compare +} + +export class IsNumber extends Validator { + constructor(...args) { + super(...args); + this.name = 'IsNumber'; + } + + // eslint-disable-next-line class-methods-use-this + execute(value) { + let isEnabled = false; + if (!isNumber(value)) { + isEnabled = true; + } + return isEnabled; + } +} + +export class MinNumber extends Validator { + constructor(...args) { + super(...args); + this.name = 'MinNumber'; + } + + execute(value, min = this.param) { + let isEnabled = false; + if (!isNumber(value) || value < min) { + isEnabled = true; + } + return isEnabled; + } +} + +export class MaxNumber extends Validator { + constructor(...args) { + super(...args); + this.name = 'MaxNumber'; + } + + execute(value, max = this.param) { + let isEnabled = false; + if (!isNumber(value) || value > max) { + isEnabled = true; + } + return isEnabled; + } +} + +export class MinMaxNumber extends Validator { + constructor(...args) { + super(...args); + this.name = 'MinMaxNumber'; + } + + execute(value, { min = 0, max = 0 } = this.param) { + let isEnabled = false; + if (!isNumber(value) || value < min || value > max) { + isEnabled = true; + } + return isEnabled; + } +} diff --git a/packages/validate/src/validators/Required.js b/packages/validate/src/validators/Required.js new file mode 100644 index 000000000..13ba04ca4 --- /dev/null +++ b/packages/validate/src/validators/Required.js @@ -0,0 +1,28 @@ +import { Validator } from '../Validator.js'; + +export class Required extends Validator { + constructor(...args) { + super(...args); + this.name = 'Required'; + } + + /** + * We don't have an execute function, since the Required validator is 'special'. + * The outcome depends on the modelValue of the FormControl and + * FormControl.__isEmpty / FormControl._isEmpty. + */ + + // eslint-disable-next-line class-methods-use-this + onFormControlConnect(formControl) { + if (formControl._inputNode) { + formControl._inputNode.setAttribute('aria-required', 'true'); + } + } + + // eslint-disable-next-line class-methods-use-this + onFormControlDisconnect(formControl) { + if (formControl._inputNode) { + formControl._inputNode.removeAttribute('aria-required'); + } + } +} diff --git a/packages/validate/src/validators/StringValidators.js b/packages/validate/src/validators/StringValidators.js new file mode 100644 index 000000000..2713a5b47 --- /dev/null +++ b/packages/validate/src/validators/StringValidators.js @@ -0,0 +1,97 @@ +/* eslint-disable max-classes-per-file */ +import { Validator } from '../Validator.js'; + +const isString = value => typeof value === 'string'; + +export class IsString extends Validator { + constructor(...args) { + super(...args); + this.name = 'IsString'; + } + + // eslint-disable-next-line class-methods-use-this + execute(value) { + let hasError = false; + if (!isString(value)) { + hasError = true; + } + return hasError; + } +} + +export class EqualsLength extends Validator { + constructor(...args) { + super(...args); + this.name = 'EqualsLength'; + } + + execute(value, length = this.param) { + let hasError = false; + if (!isString(value) || value.length !== length) { + hasError = true; + } + return hasError; + } +} + +export class MinLength extends Validator { + constructor(...args) { + super(...args); + this.name = 'MinLength'; + } + + execute(value, min = this.param) { + let hasError = false; + if (!isString(value) || value.length < min) { + hasError = true; + } + return hasError; + } +} + +export class MaxLength extends Validator { + constructor(...args) { + super(...args); + this.name = 'MaxLength'; + } + + execute(value, max = this.param) { + let hasError = false; + if (!isString(value) || value.length > max) { + hasError = true; + } + return hasError; + } +} + +export class MinMaxLength extends Validator { + constructor(...args) { + super(...args); + this.name = 'MinMaxLength'; + } + + execute(value, { min = 0, max = 0 } = this.param) { + let hasError = false; + if (!isString(value) || value.length <= min || value.length >= max) { + hasError = true; + } + return hasError; + } +} + +const isEmailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +export class IsEmail extends Validator { + constructor(...args) { + super(...args); + this.name = 'IsEmail'; + } + + // eslint-disable-next-line class-methods-use-this + execute(value) { + let hasError = false; + if (!isString(value) || !isEmailRegex.test(value.toLowerCase())) { + hasError = true; + } + return hasError; + } +} diff --git a/packages/validate/test-helpers/helper-validators.js b/packages/validate/test-helpers/helper-validators.js new file mode 100644 index 000000000..2e65d01d3 --- /dev/null +++ b/packages/validate/test-helpers/helper-validators.js @@ -0,0 +1,52 @@ +/* eslint-disable max-classes-per-file */ +import { Validator } from '../src/Validator.js'; + +export class AlwaysInvalid extends Validator { + constructor(...args) { + super(...args); + this.name = 'AlwaysInvalid'; + } + + // eslint-disable-next-line class-methods-use-this + execute() { + const showMessage = true; + return showMessage; + } +} + +export class AlwaysValid extends Validator { + constructor(...args) { + super(...args); + this.name = 'AlwaysValid'; + } + + // eslint-disable-next-line class-methods-use-this + execute() { + const showMessage = false; + return showMessage; + } +} + +export class AsyncAlwaysValid extends AlwaysValid { + constructor(...args) { + super(...args); + this.async = true; + } + + // eslint-disable-next-line class-methods-use-this + execute() { + return true; + } +} + +export class AsyncAlwaysInvalid extends AlwaysValid { + constructor(...args) { + super(...args); + this.async = true; + } + + // eslint-disable-next-line class-methods-use-this + async execute() { + return false; + } +} diff --git a/packages/validate/test-suites/FeedbackMixin.suite.js b/packages/validate/test-suites/FeedbackMixin.suite.js new file mode 100644 index 000000000..d75071e47 --- /dev/null +++ b/packages/validate/test-suites/FeedbackMixin.suite.js @@ -0,0 +1,344 @@ +/* eslint-disable max-classes-per-file, no-param-reassign, no-unused-expressions */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { expect, fixture, html, unsafeStatic, defineCE } 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 { Validator } from '../src/Validator.js'; +import { Required } from '../src/validators/Required.js'; +import { MinLength } from '../src/validators/StringValidators.js'; +import { DefaultSuccess } from '../src/resultValidators/DefaultSuccess.js'; +import { AlwaysInvalid } from '../test-helpers/helper-validators.js'; +import '../lion-validation-feedback.js'; +import { FeedbackMixin } from '../src/FeedbackMixin.js'; + +export function runFeedbackMixinSuite(customConfig) { + const cfg = { + tagString: null, + ...customConfig, + }; + + const lightDom = cfg.lightDom || ''; + + describe('Validity Feedback', () => { + afterEach(() => { + sinon.restore(); + }); + + // eslint-disable-next-line no-shadow + const tagString = defineCE( + class extends FeedbackMixin(ValidateMixin(LitElement)) { + static get properties() { + return { modelValue: String }; + } + + connectedCallback() { + super.connectedCallback(); + this.appendChild(document.createElement('input')); + } + + get _inputNode() { + return this.querySelector('input'); + } + }, + ); + // eslint-disable-next-line no-shadow + const tag = unsafeStatic(tagString); + + class ContainsLowercaseA extends Validator { + constructor(...args) { + super(...args); + this.name = 'ContainsLowercaseA'; + this.execute = modelValue => !modelValue.includes('a'); + } + } + + class ContainsCat extends Validator { + constructor(...args) { + super(...args); + this.name = 'containsCat'; + this.execute = modelValue => !modelValue.includes('cat'); + } + } + + AlwaysInvalid.getMessage = () => 'Message for AlwaysInvalid'; + MinLength.getMessage = () => 'Message for MinLength'; + ContainsLowercaseA.getMessage = () => 'Message for ContainsLowercaseA'; + ContainsCat.getMessage = () => 'Message for ContainsCat'; + + it('sets ".hasErrorVisible"/[has-error-visible] when visibility condition is met', async () => { + const el = await fixture(html` + <${tag} .validators=${[new MinLength(3)]}>${lightDom}`); + + if (cfg.enableFeedbackVisible) { + cfg.enableFeedbackVisible(el); + } + + el.modelValue = 'a'; + await el.feedbackComplete; + expect(el.hasErrorVisible).to.be.true; + expect(el.hasAttribute('has-error-visible')).to.be.true; + + el.modelValue = 'abc'; + await el.feedbackComplete; + expect(el.hasErrorVisible).to.be.false; + expect(el.hasAttribute('has-error-visible')).to.be.false; + }); + + it('passes a message to the "._feedbackNode"', async () => { + const el = await fixture(html` + <${tag} + .modelValue=${'cat'} + >${lightDom} + `); + expect(el._feedbackNode.feedbackData).to.be.undefined; + el.validators = [new AlwaysInvalid()]; + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for AlwaysInvalid'); + }); + + it('has configurable feedback visibility hook', async () => { + const el = await fixture(html` + <${tag} + .modelValue=${'cat'} + .validators=${[new AlwaysInvalid()]} + >${lightDom} + `); + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for AlwaysInvalid'); + el._prioritizeAndFilterFeedback = () => []; // filter out all errors + await el.validate(); + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData).to.be.undefined; + }); + + it('writes prioritized result to "._feedbackNode" based on Validator order', async () => { + const el = await fixture(html` + <${tag} + .modelValue=${'cat'} + .validators=${[new AlwaysInvalid(), new MinLength(4)]} + >${lightDom} + `); + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for AlwaysInvalid'); + }); + + it('renders validation result to "._feedbackNode" when async messages are resolved', async () => { + let unlockMessage; + const messagePromise = new Promise(resolve => { + unlockMessage = resolve; + }); + + AlwaysInvalid.getMessage = async () => { + await messagePromise; + return 'this ends up in "._feedbackNode"'; + }; + + const el = await fixture(html` + <${tag} + .modelValue=${'cat'} + .validators=${[new AlwaysInvalid()]} + >${lightDom} + `); + expect(el._feedbackNode.feedbackData).to.be.undefined; + unlockMessage(); + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('this ends up in "._feedbackNode"'); + }); + + // N.B. this replaces the 'config.hideFeedback' option we had before... + it('renders empty result when Validator.getMessage() returns "null"', async () => { + let unlockMessage; + const messagePromise = new Promise(resolve => { + unlockMessage = resolve; + }); + + AlwaysInvalid.getMessage = async () => { + await messagePromise; + return 'this ends up in "._feedbackNode"'; + }; + + const el = await fixture(html` + <${tag} + .modelValue=${'cat'} + .validators=${[new AlwaysInvalid()]} + >${lightDom} + `); + + expect(el._feedbackNode.feedbackData).to.be.undefined; + unlockMessage(); + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('this ends up in "._feedbackNode"'); + }); + + it('supports custom element to render feedback', async () => { + const customFeedbackTagString = defineCE( + class extends LitElement { + static get properties() { + return { + feedbackData: Array, + }; + } + + render() { + return html` + Custom for ${this.feedbackData[0].validator.name} + `; + } + }, + ); + const customFeedbackTag = unsafeStatic(customFeedbackTagString); + const el = await fixture(html` + <${tag} + .validators=${[new ContainsLowercaseA(), new AlwaysInvalid()]}> + <${customFeedbackTag} slot="feedback"><${customFeedbackTag}> + + `); + + expect(el._feedbackNode.localName).to.equal(customFeedbackTagString); + + el.modelValue = 'dog'; + await el.feedbackComplete; + await el._feedbackNode.updateComplete; + expect(el._feedbackNode).shadowDom.to.equal('Custom for ContainsLowercaseA'); + + el.modelValue = 'cat'; + await el.feedbackComplete; + await el._feedbackNode.updateComplete; + expect(el._feedbackNode).shadowDom.to.equal('Custom for AlwaysInvalid'); + }); + + it('supports custom messages in Validator instance configuration object', async () => { + const el = await fixture(html` + <${tag} .validators=${[new MinLength(3, { getMessage: () => 'custom via config' })]} + >${lightDom} + `); + + el.modelValue = 'a'; + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('custom via config'); + }); + + it('shows success message after fixing an error', async () => { + const el = await fixture(html` + <${tag} + .validators=${[ + new MinLength(3), + new DefaultSuccess(null, { getMessage: () => 'This is a success message' }), + ]} + >${lightDom} + `); + + el.modelValue = 'a'; + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for MinLength'); + + el.modelValue = 'abcd'; + await el.feedbackComplete; + expect(el._feedbackNode.feedbackData[0].message).to.equal('This is a success message'); + }); + + describe('Accessibility', () => { + it('sets [aria-invalid="true"] to "._inputNode" when ".hasError" is true', async () => { + const el = await fixture(html` + <${tag} + .validators=${[new Required()]} + .modelValue=${'a'} + >${lightDom} + `); + const inputNode = el._inputNode; + + expect(inputNode.getAttribute('aria-invalid')).to.equal('false'); + + el.modelValue = ''; + await el.feedbackComplete; + expect(inputNode.getAttribute('aria-invalid')).to.equal('true'); + el.modelValue = 'a'; + await el.feedbackComplete; + expect(inputNode.getAttribute('aria-invalid')).to.equal('false'); + }); + }); + + describe('Meta data', () => { + it('".getMessage()" gets a reference to formControl, validatorParams and modelValue', async () => { + let el; + const constructorValidator = new MinLength(4, { type: 'x' }); // type to prevent duplicates + const constructorMessageSpy = sinon.spy(constructorValidator.constructor, 'getMessage'); + + el = await fixture(html` + <${tag} + .validators=${[constructorValidator]} + .modelValue=${'cat'} + >${lightDom} + `); + await el.feedbackComplete; + expect(constructorMessageSpy.args[0][0]).to.eql({ + validatorParams: 4, + modelValue: 'cat', + formControl: el, + fieldName: undefined, + }); + + const instanceMessageSpy = sinon.spy(); + const instanceValidator = new MinLength(4, { getMessage: instanceMessageSpy }); + + el = await fixture(html` + <${tag} + .validators=${[instanceValidator]} + .modelValue=${'cat'} + >${lightDom} + `); + await el.feedbackComplete; + expect(instanceMessageSpy.args[0][0]).to.eql({ + validatorParams: 4, + modelValue: 'cat', + formControl: el, + fieldName: undefined, + }); + }); + + it('".getMessage()" gets .fieldName defined on instance', async () => { + const constructorValidator = new MinLength(4, { type: 'x' }); // type to prevent duplicates + const spy = sinon.spy(constructorValidator.constructor, 'getMessage'); + + const el = await fixture(html` + <${tag} + .validators=${[constructorValidator]} + .modelValue=${'cat'} + .fieldName=${new Promise(resolve => resolve('myField'))} + >${lightDom} + `); + await el.feedbackComplete; + expect(spy.args[0][0]).to.eql({ + validatorParams: 4, + modelValue: 'cat', + formControl: el, + fieldName: 'myField', + }); + }); + }); + + it('".getMessage()" gets .fieldName defined on Validator config', async () => { + const constructorValidator = new MinLength(4, { + fieldName: new Promise(resolve => resolve('myFieldViaCfg')), + }); + const spy = sinon.spy(constructorValidator.constructor, 'getMessage'); + + const el = await fixture(html` + <${tag} + .validators=${[constructorValidator]} + .modelValue=${'cat'} + .fieldName=${new Promise(resolve => resolve('myField'))} + >${lightDom} + `); + await el.feedbackComplete; + expect(spy.args[0][0]).to.eql({ + validatorParams: 4, + modelValue: 'cat', + formControl: el, + fieldName: 'myFieldViaCfg', + }); + }); + }); +} diff --git a/packages/validate/test-suites/ValidateMixin.suite.js b/packages/validate/test-suites/ValidateMixin.suite.js new file mode 100644 index 000000000..4772ca2d5 --- /dev/null +++ b/packages/validate/test-suites/ValidateMixin.suite.js @@ -0,0 +1,1155 @@ +/* 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 + }); + }); + }); +} diff --git a/packages/validate/test/DateValidators.test.js b/packages/validate/test/DateValidators.test.js new file mode 100644 index 000000000..ea392c247 --- /dev/null +++ b/packages/validate/test/DateValidators.test.js @@ -0,0 +1,99 @@ +import { expect } from '@open-wc/testing'; + +import { normalizeDateTime } from '@lion/localize'; +import { + IsDate, + MinDate, + MaxDate, + MinMaxDate, + IsDateDisabled, +} from '../src/validators/DateValidators.js'; + +describe('Date Validation', () => { + it('provides new isDate() to allow only dates', () => { + let isEnabled; + const validator = new IsDate(); + expect(validator.name).to.equal('IsDate'); + + isEnabled = validator.execute(new Date()); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute(4); + expect(isEnabled).to.be.true; + }); + + it('provides new minDate(x) to allow only dates after min', () => { + let isEnabled; + const validator = new MinDate(new Date('2018/02/02')); + expect(validator.name).to.equal('MinDate'); + + isEnabled = validator.execute(new Date('2018-02-03')); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(new Date('2018-02-01')); + expect(isEnabled).to.be.true; + + const today = new Date(); + const todayFormatted = normalizeDateTime(today); + const todayValidator = new MinDate(today); + isEnabled = todayValidator.execute(todayFormatted); + expect(isEnabled).to.be.false; + }); + + it('provides maxDate() to allow only dates before max', () => { + let isEnabled; + const validator = new MaxDate(new Date('2018/02/02')); + expect(validator.name).to.equal('MaxDate'); + + isEnabled = validator.execute(new Date('2018-02-01')); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(new Date('2018-02-03')); + expect(isEnabled).to.be.true; + + const today = new Date(); + const todayFormatted = normalizeDateTime(today); + const todayValidator = new MaxDate(today); + isEnabled = todayValidator.execute(todayFormatted); + expect(isEnabled).to.be.false; + }); + + it('provides new MinMaxDate() to allow only dates between min and max', () => { + let isEnabled; + const validator = new MinMaxDate({ + min: new Date('2018/02/02'), + max: new Date('2018/02/04'), + }); + expect(validator.name).to.equal('MinMaxDate'); + + isEnabled = validator.execute(new Date('2018/02/03')); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(new Date('2018/02/01')); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute(new Date('2018/02/05')); + expect(isEnabled).to.be.true; + + const today = new Date(); + const todayFormatted = normalizeDateTime(today); + const todayValidator = new MinMaxDate({ min: today, max: today }); + isEnabled = todayValidator.execute(todayFormatted); + expect(isEnabled).to.be.false; + }); + + it('provides new IsDateDisabled() to disable dates matching specified condition', () => { + let isDisabled; + const validator = new IsDateDisabled(d => d.getDate() === 3); + expect(validator.name).to.equal('IsDateDisabled'); + + isDisabled = validator.execute(new Date('2018/02/04')); + expect(isDisabled).to.be.false; + + isDisabled = validator.execute(new Date('2018/02/03')); + expect(isDisabled).to.be.true; + }); +}); diff --git a/packages/validate/test/FeedbackMixin.test.js b/packages/validate/test/FeedbackMixin.test.js new file mode 100644 index 000000000..518d37446 --- /dev/null +++ b/packages/validate/test/FeedbackMixin.test.js @@ -0,0 +1,3 @@ +import { runFeedbackMixinSuite } from '../test-suites/FeedbackMixin.suite.js'; + +runFeedbackMixinSuite(); diff --git a/packages/validate/test/NumberValidators.test.js b/packages/validate/test/NumberValidators.test.js new file mode 100644 index 000000000..43aadbaea --- /dev/null +++ b/packages/validate/test/NumberValidators.test.js @@ -0,0 +1,66 @@ +import { expect } from '@open-wc/testing'; + +import { + IsNumber, + MinNumber, + MaxNumber, + MinMaxNumber, +} from '../src/validators/NumberValidators.js'; + +describe('Number Validation', () => { + it('provides new IsNumber() to allow only numbers', () => { + let isEnabled; + const validator = new IsNumber(); + expect(validator.name).to.equal('IsNumber'); + + isEnabled = validator.execute(4); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(NaN); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('4'); + expect(isEnabled).to.be.true; + }); + + it('provides new MinNumber(x) to allow only numbers longer then min', () => { + let isEnabled; + const validator = new MinNumber(3); + expect(validator.name).to.equal('MinNumber'); + + isEnabled = validator.execute(3); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(2); + expect(isEnabled).to.be.true; + }); + + it('provides new MaxNumber(x) to allow only number shorter then max', () => { + let isEnabled; + const validator = new MaxNumber(3); + expect(validator.name).to.equal('MaxNumber'); + + isEnabled = validator.execute(3); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(4); + expect(isEnabled).to.be.true; + }); + + it('provides new MinMaxNumber({ min: x, max: y}) to allow only numbers between min and max', () => { + let isEnabled; + const validator = new MinMaxNumber({ min: 2, max: 4 }); + expect(validator.name).to.equal('MinMaxNumber'); + + isEnabled = validator.execute(2); + expect(isEnabled).to.be.false; + isEnabled = validator.execute(4); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(1); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute(5); + expect(isEnabled).to.be.true; + }); +}); diff --git a/packages/validate/test/StringValidators.test.js b/packages/validate/test/StringValidators.test.js new file mode 100644 index 000000000..b29d7aaaf --- /dev/null +++ b/packages/validate/test/StringValidators.test.js @@ -0,0 +1,114 @@ +import { expect } from '@open-wc/testing'; + +import { + IsString, + EqualsLength, + MinLength, + MaxLength, + MinMaxLength, + IsEmail, +} from '../src/validators/StringValidators.js'; + +describe('String Validation', () => { + it('provides new IsString() to allow only strings', () => { + let isEnabled; + const validator = new IsString(); + expect(validator.name).to.equal('IsString'); + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute(NaN); + expect(validator.execute(NaN)).to.be.true; + + isEnabled = validator.execute(4); + expect(validator.execute(4)).to.be.true; + }); + + it('provides new EqualsLength(x) to allow only a specific string length', () => { + let isEnabled; + const validator = new EqualsLength(3); + expect(validator.name).to.equal('EqualsLength'); + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('fo'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('foobar'); + expect(isEnabled).to.be.true; + }); + + it('provides new MinLength(x) to allow only strings longer then min', () => { + let isEnabled; + const validator = new MinLength(3); + expect(validator.name).to.equal('MinLength'); + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('fo'); + expect(isEnabled).to.be.true; + }); + + it('provides new MaxLength(x) to allow only strings shorter then max', () => { + let isEnabled; + const validator = new MaxLength(3); + expect(validator.name).to.equal('MaxLength'); + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('foobar'); + expect(isEnabled).to.be.true; + }); + + it('provides new MinMaxValidator({ min: x, max: y}) to allow only strings between min and max', () => { + let isEnabled; + const validator = new MinMaxLength({ min: 2, max: 4 }); + expect(validator.name).to.equal('MinMaxLength'); + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('f'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('foobar'); + expect(isEnabled).to.be.true; + }); + + it('provides new IsEmail() to allow only valid email formats', () => { + let isEnabled; + const validator = new IsEmail(); + expect(validator.name).to.equal('IsEmail'); + + isEnabled = validator.execute('foo@bar.com'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('name!#$%*@bar.com'); + expect(isEnabled).to.be.false; + + isEnabled = validator.execute('foo'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('foo@'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('bar.com'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('@bar.com'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('foo@bar@example.com'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('foo@bar'); + expect(isEnabled).to.be.true; + + isEnabled = validator.execute('foo@120.120.120.93'); + expect(isEnabled).to.be.true; + }); +}); diff --git a/packages/validate/test/SyncUpdatableMixin.test.js b/packages/validate/test/SyncUpdatableMixin.test.js new file mode 100644 index 000000000..4c25c1326 --- /dev/null +++ b/packages/validate/test/SyncUpdatableMixin.test.js @@ -0,0 +1,255 @@ +import { expect, fixtureSync, defineCE, unsafeStatic, html, fixture } from '@open-wc/testing'; +import sinon from 'sinon'; +import { UpdatingElement } from '@lion/core'; +import { SyncUpdatableMixin } from '../src/utils/SyncUpdatableMixin.js'; + +describe('SyncUpdatableMixin', () => { + describe('Until firstUpdated', () => { + it('initializes all properties', async () => { + let hasCalledFirstUpdated = false; + let hasCalledUpdateSync = false; + + const tagString = defineCE( + class extends SyncUpdatableMixin(UpdatingElement) { + static get properties() { + return { + propA: { type: String }, + propB: { + type: String, + attribute: 'prop-b', + }, + }; + } + + constructor() { + super(); + this.propA = 'init-a'; + this.propB = 'init-b'; + } + + firstUpdated(c) { + super.firstUpdated(c); + hasCalledFirstUpdated = true; + } + + updateSync(...args) { + super.updateSync(...args); + hasCalledUpdateSync = true; + } + }, + ); + const tag = unsafeStatic(tagString); + const el = fixtureSync(html`<${tag} prop-b="b">`); + + // Getters setters work as expected, without running property effects + expect(el.propA).to.equal('init-a'); + expect(el.propB).to.equal('b'); + el.propA = 'a2'; + expect(el.propA).to.equal('a2'); + expect(hasCalledFirstUpdated).to.be.false; + expect(hasCalledUpdateSync).to.be.false; + + await el.updateComplete; + expect(hasCalledFirstUpdated).to.be.true; + expect(hasCalledUpdateSync).to.be.true; + }); + + // See: https://github.com/webcomponents/gold-standard/wiki/Member-Order-Independence + it('guarantees Member Order Independence', async () => { + let hasCalledRunPropertyEffect = false; + + const tagString = defineCE( + class extends SyncUpdatableMixin(UpdatingElement) { + static get properties() { + return { + propA: { type: String }, + propB: { + type: String, + attribute: 'prop-b', + }, + derived: { type: String }, + }; + } + + constructor() { + super(); + this.propA = 'init-a'; + this.propB = 'init-b'; + } + + updateSync(name, oldValue) { + super.updateSync(name, oldValue); + + if (name === 'propB') { + this._runPropertyEffect(); + } + } + + _runPropertyEffect() { + hasCalledRunPropertyEffect = true; + this.derived = this.propA + this.propB; + } + }, + ); + const tag = unsafeStatic(tagString); + const el = fixtureSync(html`<${tag} prop-b="b" .propA="${'a'}">`); + + // Derived + expect(el.derived).to.be.undefined; + expect(hasCalledRunPropertyEffect).to.be.false; + + await el.updateComplete; + expect(el.derived).to.equal('ab'); + expect(hasCalledRunPropertyEffect).to.be.true; + + const el2 = await fixture(html`<${tag} .propA="${'a'}">`); + expect(el2.derived).to.equal('ainit-b'); + + const el3 = await fixture(html`<${tag} .propB="${'b'}">`); + expect(el3.derived).to.equal('init-ab'); + + const el4 = await fixture(html`<${tag} .propA=${'a'} .propB="${'b'}">`); + expect(el4.derived).to.equal('ab'); + }); + + it('runs "updateSync" once per property with most current value', async () => { + let propChangedCount = 0; + let propUpdateSyncCount = 0; + + const tagString = defineCE( + class extends SyncUpdatableMixin(UpdatingElement) { + static get properties() { + return { + prop: { type: String }, + }; + } + + constructor() { + super(); + this.prop = 'a'; + } + + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + if (name === 'prop') { + propChangedCount += 1; + } + } + + updateSync(name, oldValue) { + super.updateSync(name, oldValue); + if (name === 'prop') { + propUpdateSyncCount += 1; + } + } + }, + ); + const tag = unsafeStatic(tagString); + const el = fixtureSync(html`<${tag}>`); + el.prop = 'a'; + // Getters setters work as expected, without running property effects + expect(propChangedCount).to.equal(2); + expect(propUpdateSyncCount).to.equal(0); + + await el.updateComplete; + expect(propChangedCount).to.equal(2); + expect(propUpdateSyncCount).to.equal(1); + }); + }); + + describe('After firstUpdated', () => { + it('calls "updateSync" immediately when the observed property is changed (newValue !== oldValue)', async () => { + const tagString = defineCE( + class extends SyncUpdatableMixin(UpdatingElement) { + static get properties() { + return { + propA: { type: String }, + propB: { + type: String, + attribute: 'prop-b', + }, + derived: { type: String }, + }; + } + + constructor() { + super(); + this.propA = 'init-a'; + this.propB = 'init-b'; + } + + updateSync(name, oldValue) { + super.updateSync(name, oldValue); + + if (name === 'propB') { + this._runPropertyEffect(); + } + } + + _runPropertyEffect() { + this.derived = this.propA + this.propB; + } + }, + ); + const tag = unsafeStatic(tagString); + const el = fixtureSync(html`<${tag} prop-b="b" .propA="${'a'}">`); + const spy = sinon.spy(el, '_runPropertyEffect'); + expect(spy.callCount).to.equal(0); + + await el.updateComplete; + expect(el.derived).to.equal('ab'); + expect(spy.callCount).to.equal(1); + el.propB = 'b2'; + expect(el.derived).to.equal('ab2'); + expect(spy.callCount).to.equal(2); + }); + }); + + describe('Features', () => { + // See: https://lit-element.polymer-project.org/guide/lifecycle#haschanged + it('supports "hasChanged" from UpdatingElement', async () => { + const tagString = defineCE( + class extends SyncUpdatableMixin(UpdatingElement) { + static get properties() { + return { + complexProp: { + type: Object, + hasChanged: (result, prevResult) => { + // Simple way of doing a deep comparison + if (JSON.stringify(result) !== JSON.stringify(prevResult)) { + return true; + } + return false; + }, + }, + }; + } + + updateSync(name, oldValue) { + super.updateSync(name, oldValue); + + if (name === 'complexProp') { + this._onComplexPropChanged(); + } + } + + _onComplexPropChanged() { + // do smth + } + }, + ); + const tag = unsafeStatic(tagString); + const el = fixtureSync(html`<${tag}>`); + const spy = sinon.spy(el, '_onComplexPropChanged'); + await el.updateComplete; + + expect(spy.callCount).to.equal(0); + el.complexProp = { key1: true }; + expect(spy.callCount).to.equal(1); + el.complexProp = { key1: false }; + expect(spy.callCount).to.equal(2); + el.complexProp = { key1: false }; + expect(spy.callCount).to.equal(2); + }); + }); +}); diff --git a/packages/validate/test/Unparseable.test.js b/packages/validate/test/Unparseable.test.js deleted file mode 100644 index b278ab310..000000000 --- a/packages/validate/test/Unparseable.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { expect } from '@open-wc/testing'; -import { Unparseable } from '../src/Unparseable.js'; - -describe('Unparseable', () => { - it(`can be instantiated`, async () => { - const instance = new Unparseable('my view value'); - expect(instance instanceof Unparseable).to.equal(true); - }); - it(`contains a viewValue`, async () => { - const instance = new Unparseable('my view value'); - expect(instance.viewValue).to.equal('my view value'); - }); - it(`contains a type`, async () => { - const instance = new Unparseable('my view value'); - expect(instance.type).to.equal('unparseable'); - }); - it(`is serialized as an object`, async () => { - const instance = new Unparseable('my view value'); - expect(instance.toString()).to.equal('{"type":"unparseable","viewValue":"my view value"}'); - }); -}); diff --git a/packages/validate/test/ValidateMixin.test.js b/packages/validate/test/ValidateMixin.test.js index b43e770b0..8ae9c75a6 100644 --- a/packages/validate/test/ValidateMixin.test.js +++ b/packages/validate/test/ValidateMixin.test.js @@ -1,3 +1,4 @@ +<<<<<<< HEAD /* eslint-disable no-unused-vars, no-param-reassign */ import { expect, fixture, html, unsafeStatic, defineCE, aTimeout } from '@open-wc/testing'; import sinon from 'sinon'; @@ -1341,3 +1342,8 @@ describe('ValidateMixin', () => { }); }); }); +======= +import { runValidateMixinSuite } from '../test-suites/ValidateMixin.suite.js'; + +runValidateMixinSuite(); +>>>>>>> feat(validate): new validation api, async validation and more diff --git a/packages/validate/test/Validator.test.js b/packages/validate/test/Validator.test.js new file mode 100644 index 000000000..9b7946c38 --- /dev/null +++ b/packages/validate/test/Validator.test.js @@ -0,0 +1,128 @@ +import { expect, fixture, html, unsafeStatic, defineCE } from '@open-wc/testing'; +import { LitElement } from '@lion/core'; +import sinon from 'sinon'; +import { ValidateMixin } from '../src/ValidateMixin.js'; +import { Validator } from '../src/Validator.js'; +import { ResultValidator } from '../src/ResultValidator.js'; +import { Required } from '../src/validators/Required.js'; +import { MinLength } from '../src/validators/StringValidators.js'; + +describe('Validator', () => { + it('has an "execute" function returning "shown" state', async () => { + class MyValidator extends Validator { + execute(modelValue, param) { + const hasError = modelValue === 'test' && param === 'me'; + return hasError; + } + } + expect(new MyValidator().execute('test', 'me')).to.be.true; + }); + + it('receives a "param" as a first argument on instantiation', async () => { + const vali = new Validator('myParam'); + expect(vali.param).to.equal('myParam'); + }); + + it('receives a config object (optionally) as a second argument on instantiation', async () => { + const vali = new Validator('myParam', { my: 'config' }); + expect(vali.config).to.eql({ my: 'config' }); + }); + + it('fires "param-changed" event on param change', async () => { + const vali = new Validator('foo'); + const cb = sinon.spy(() => {}); + vali.addEventListener('param-changed', cb); + vali.param = 'bar'; + expect(cb.callCount).to.equal(1); + }); + + it('fires "config-changed" event on config change', async () => { + const vali = new Validator('foo', { foo: 'bar' }); + const cb = sinon.spy(() => {}); + vali.addEventListener('config-changed', cb); + vali.config = { bar: 'foo' }; + expect(cb.callCount).to.equal(1); + }); + + it('has access to FormControl', async () => { + const lightDom = ''; + const tagString = defineCE( + class extends ValidateMixin(LitElement) { + static get properties() { + return { modelValue: String }; + } + }, + ); + const tag = unsafeStatic(tagString); + + class MyValidator extends Validator { + execute(modelValue, param) { + const hasError = modelValue === 'forbidden' && param === 'values'; + return hasError; + } + + // eslint-disable-next-line + onFormControlConnect(formControl) { + // I could do something like: + // - add aria-required="true" + // - add type restriction for MaxLength(3, { isBlocking: true }) + } + + // eslint-disable-next-line + onFormControlDisconnect(formControl) { + // I will cleanup what I did in connect + } + } + const myVal = new MyValidator(); + const connectSpy = sinon.spy(myVal, 'onFormControlConnect'); + const disconnectSpy = sinon.spy(myVal, 'onFormControlDisconnect'); + + const el = await fixture(html` + <${tag} .validators=${[myVal]}>${lightDom} + `); + + expect(connectSpy.callCount).to.equal(1); + expect(connectSpy.calledWith(el)).to.equal(true); + expect(disconnectSpy.callCount).to.equal(0); + + el.validators = []; + expect(connectSpy.callCount).to.equal(1); + expect(disconnectSpy.callCount).to.equal(1); + expect(disconnectSpy.calledWith(el)).to.equal(true); + }); + + describe('Types', () => { + it('has type "error" by default', async () => { + expect(new Validator().type).to.equal('error'); + }); + + it('supports customized types', async () => { + // This test shows the best practice of adding custom types + class MyValidator extends Validator { + constructor(...args) { + super(...args); + this.type = 'my-type'; + } + } + expect(new MyValidator().type).to.equal('my-type'); + }); + }); +}); + +describe('ResultValidator', () => { + it('has an "executeOnResults" function returning active state', async () => { + // This test shows the best practice of creating executeOnResults method + class MyResultValidator extends ResultValidator { + executeOnResults({ regularValidateResult, prevValidationResult }) { + const hasSuccess = regularValidateResult.length && !prevValidationResult.length; + return hasSuccess; + } + } + expect( + new MyResultValidator().executeOnResults({ + regularValidateResult: [new Required(), new MinLength(3)], + prevValidationResult: [], + }), + ).to.be.true; + }); +}); diff --git a/packages/validate/test/isValidatorApplied.test.js b/packages/validate/test/isValidatorApplied.test.js deleted file mode 100644 index 08a3408c0..000000000 --- a/packages/validate/test/isValidatorApplied.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { expect } from '@open-wc/testing'; -import { isValidatorApplied } from '../src/isValidatorApplied.js'; - -describe('isValidatorApplied', () => { - it(`checks if validator (provided name string) is applied`, async () => { - const myValFn = (val, param) => ({ myValFn: param === 'x' }); - const myOtherValFn = (val, param) => ({ myOtherValFn: param === 'x' }); - - expect(isValidatorApplied('myValFn', myValFn, 'x')).to.equal(true); - expect(isValidatorApplied('myValFn', myValFn, 'y')).to.equal(true); - - expect(isValidatorApplied('myValFn', myOtherValFn, 'x')).to.equal(false); - expect(isValidatorApplied('myValFn', myOtherValFn, 'y')).to.equal(false); - }); -}); diff --git a/packages/validate/test/lion-validation-feedback.test.js b/packages/validate/test/lion-validation-feedback.test.js new file mode 100644 index 000000000..ca450b5f0 --- /dev/null +++ b/packages/validate/test/lion-validation-feedback.test.js @@ -0,0 +1,18 @@ +/* eslint-disable no-unused-vars, no-param-reassign */ +import { fixture, html, expect } from '@open-wc/testing'; +import '../lion-validation-feedback.js'; +import { AlwaysInvalid } from '../test-helpers/helper-validators.js'; + +describe('lion-validation-feedback', () => { + it('renders a validation message', async () => { + const el = await fixture( + html` + + `, + ); + expect(el).shadowDom.to.equal(''); + el.feedbackData = [{ message: 'hello', type: 'error', validator: new AlwaysInvalid() }]; + await el.updateComplete; + expect(el).shadowDom.to.equal('hello'); + }); +}); diff --git a/packages/validate/test/validators.test.js b/packages/validate/test/validators.test.js deleted file mode 100644 index 3a3262574..000000000 --- a/packages/validate/test/validators.test.js +++ /dev/null @@ -1,208 +0,0 @@ -import { expect } from '@open-wc/testing'; -import { normalizeDateTime } from '@lion/localize'; -import { smokeTestValidator } from '../test-helpers.js'; - -import { - isString, - equalsLength, - minLength, - maxLength, - minMaxLength, - isEmail, - isStringValidator, - equalsLengthValidator, - minLengthValidator, - maxLengthValidator, - minMaxLengthValidator, - isEmailValidator, - isNumber, - minNumber, - maxNumber, - minMaxNumber, - isNumberValidator, - minNumberValidator, - maxNumberValidator, - minMaxNumberValidator, - isDate, - minDate, - maxDate, - isDateDisabled, - minMaxDate, - isDateValidator, - minDateValidator, - maxDateValidator, - minMaxDateValidator, - isDateDisabledValidator, - randomOk, - defaultOk, - randomOkValidator, - defaultOkValidator, -} from '../src/validators.js'; - -describe('LionValidate', () => { - describe('String Validation', () => { - it('provides isString() to allow only strings', () => { - expect(isString('foo')).to.be.true; - expect(isString(NaN)).to.be.false; - expect(isString(4)).to.be.false; - }); - - it('provides equalsLength() to allow only a specific string length', () => { - expect(equalsLength('foo', 3)).to.be.true; - expect(equalsLength('fo', 3)).to.be.false; - expect(equalsLength('foobar', 3)).to.be.false; - }); - - it('provides minLength() to allow only strings longer then min', () => { - expect(minLength('foo', 3)).to.be.true; - expect(minLength('fo', 3)).to.be.false; - }); - - it('provides maxLength() to allow only strings shorter then max', () => { - expect(maxLength('foo', 3)).to.be.true; - expect(maxLength('foobar', 3)).to.be.false; - }); - - it('provides minMaxLength() to allow only strings between min and max', () => { - expect(minMaxLength('foo', { min: 2, max: 4 })).to.be.true; - expect(minMaxLength('f', { min: 2, max: 4 })).to.be.false; - expect(minMaxLength('foobar', { min: 2, max: 4 })).to.be.false; - }); - - it('provides isEmail() to allow only valid email formats', () => { - expect(isEmail('foo@bar.com')).to.be.true; - expect(isEmail('name!#$%*@bar.com')).to.be.true; - expect(isEmail('foo')).to.be.false; - expect(isEmail('foo@')).to.be.false; - expect(isEmail('@bar')).to.be.false; - expect(isEmail('bar.com')).to.be.false; - expect(isEmail('@bar.com')).to.be.false; - expect(isEmail('foo@bar@example.com')).to.be.false; - expect(isEmail('foo@bar')).to.be.false; - expect(isEmail('foo@120.120.120.93')).to.be.false; - }); - - it('provides {isString, equalsLength, minLength, maxLength, minMaxLength, isEmail}Validator factory function for all types', () => { - // do a smoke test for each type - smokeTestValidator('isString', isStringValidator, 'foo'); - smokeTestValidator('equalsLength', equalsLengthValidator, 'foo', 3); - smokeTestValidator('minLength', minLengthValidator, 'foo', 3); - smokeTestValidator('maxLength', maxLengthValidator, 'foo', 3); - smokeTestValidator('minMaxLength', minMaxLengthValidator, 'foo', { min: 2, max: 4 }); - smokeTestValidator('isEmail', isEmailValidator, 'foo@bar.com'); - }); - }); - - describe('Number Validation', () => { - it('provides isNumber() to allow only numbers', () => { - expect(isNumber(4)).to.be.true; - expect(isNumber(NaN)).to.be.false; - expect(isNumber('4')).to.be.false; - }); - - it('provides minNumber() to allow only numbers longer then min', () => { - expect(minNumber(3, 3)).to.be.true; - expect(minNumber(2, 3)).to.be.false; - }); - - it('provides maxNumber() to allow only number shorter then max', () => { - expect(maxNumber(3, 3)).to.be.true; - expect(maxNumber(4, 3)).to.be.false; - }); - - it('provides minMaxNumber() to allow only numbers between min and max', () => { - expect(minMaxNumber(3, { min: 2, max: 4 })).to.be.true; - expect(minMaxNumber(1, { min: 2, max: 4 })).to.be.false; - expect(minMaxNumber(5, { min: 2, max: 4 })).to.be.false; - }); - - it('provides {isNumber, minNumber, maxNumber, minMaxNumber}Validator factory function for all types', () => { - // do a smoke test for each type - smokeTestValidator('isNumber', isNumberValidator, 4); - smokeTestValidator('minNumber', minNumberValidator, 3, 3); - smokeTestValidator('maxNumber', maxNumberValidator, 3, 3); - smokeTestValidator('minMaxNumber', minMaxNumberValidator, 3, { min: 2, max: 4 }); - }); - }); - - describe('Date Validation', () => { - it('provides isDate() to allow only dates', () => { - expect(isDate(new Date())).to.be.true; - expect(isDate('foo')).to.be.false; - expect(isDate(4)).to.be.false; - }); - - it('provides minDate() to allow only dates after min', () => { - expect(minDate(new Date('2018-02-03'), new Date('2018/02/02'))).to.be.true; - expect(minDate(new Date('2018-02-01'), new Date('2018/02/02'))).to.be.false; - const today = new Date(); - const todayFormatted = normalizeDateTime(today); - expect(minDate(todayFormatted, today)).to.be.true; - }); - - it('provides maxDate() to allow only dates before max', () => { - expect(maxDate(new Date('2018-02-01'), new Date('2018/02/02'))).to.be.true; - expect(maxDate(new Date('2018-02-03'), new Date('2018/02/02'))).to.be.false; - const today = new Date(); - const todayFormatted = normalizeDateTime(today); - expect(maxDate(todayFormatted, today)).to.be.true; - }); - - it('provides minMaxDate() to allow only dates between min and max', () => { - const minMaxSetting = { - min: new Date('2018/02/02'), - max: new Date('2018/02/04'), - }; - expect(minMaxDate(new Date('2018/02/03'), minMaxSetting)).to.be.true; - expect(minMaxDate(new Date('2018/02/01'), minMaxSetting)).to.be.false; - expect(minMaxDate(new Date('2018/02/05'), minMaxSetting)).to.be.false; - const today = new Date(); - const todayFormatted = normalizeDateTime(today); - expect(minMaxDate(todayFormatted, { min: today, max: today })).to.be.true; - }); - - it('provides isDateDisabled() to disable dates matching specified condition', () => { - expect(isDateDisabled(new Date('2018/02/03'), d => d.getDate() === 3)).to.be.false; - expect(isDateDisabled(new Date('2018/02/04'), d => d.getDate() === 3)).to.be.true; - }); - - it('provides {isDate, minDate, maxDate, minMaxDate, isDateDisabled}Validator factory function for all types', () => { - // do a smoke test for each type - smokeTestValidator('isDate', isDateValidator, new Date()); - smokeTestValidator( - 'minDate', - minDateValidator, - new Date('2018/02/03'), - new Date('2018/02/02'), - ); - smokeTestValidator( - 'maxDate', - maxDateValidator, - new Date('2018/02/01'), - new Date('2018/02/02'), - ); - const minMaxSetting = { - min: new Date('2018/02/02'), - max: new Date('2018/02/04'), - }; - smokeTestValidator('minMaxDate', minMaxDateValidator, new Date('2018/02/03'), minMaxSetting); - smokeTestValidator( - 'isDateDisabled', - isDateDisabledValidator, - new Date('2018/02/03'), - d => d.getDate() === 15, - ); - }); - }); - - describe('Success Validation', () => { - it('provides randomOk() which fails always, so it can show the succeeds message', () => { - expect(randomOk('foo')).to.be.false; - expect(randomOkValidator()[0]('foo').randomOk).to.be.false; - }); - it('provides defaultOk() which fails always, so it can show the succeeds message', () => { - expect(defaultOk('foo')).to.be.false; - expect(defaultOkValidator()[0]('foo').defaultOk).to.be.false; - }); - }); -});