feat(validate): new validation api, async validation and more

Co-authored-by: Thomas Allmer <Thomas.Allmer@ing.com>
This commit is contained in:
Thijs Louisse 2019-10-30 10:41:04 +01:00 committed by Thomas Allmer
parent e1485188a0
commit 6e81b55e3c
33 changed files with 3535 additions and 924 deletions

View file

@ -0,0 +1,17 @@
<!-- markdownlint-disable MD041 -->
```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]
```

View file

@ -1,40 +1,26 @@
export { ValidateMixin } from './src/ValidateMixin.js'; export { ValidateMixin } from './src/ValidateMixin.js';
export { FeedbackMixin } from './src/FeedbackMixin.js';
export { Unparseable } from './src/Unparseable.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 { export {
defaultOk, IsString,
defaultOkValidator, EqualsLength,
isDateDisabled, MinLength,
isDateDisabledValidator, MaxLength,
equalsLength, MinMaxLength,
equalsLengthValidator, IsEmail,
isDate, } from './src/validators/StringValidators.js';
isDateValidator,
isEmail, export {
isEmailValidator, IsDate,
isNumber, MinDate,
isNumberValidator, MaxDate,
isString, MinMaxDate,
isStringValidator, IsDateDisabled,
maxDate, } from './src/validators/DateValidators.js';
maxDateValidator,
maxLength,
maxLengthValidator,
maxNumber,
maxNumberValidator,
minDate,
minDateValidator,
minLength,
minLengthValidator,
minMaxDate,
minMaxDateValidator,
minMaxLength,
minMaxLengthValidator,
minMaxNumber,
minMaxNumberValidator,
minNumber,
minNumberValidator,
randomOk,
randomOkValidator,
} from './src/validators.js';

View file

@ -0,0 +1,3 @@
import { LionValidationFeedback } from './src/LionValidationFeedback.js';
customElements.define('lion-validation-feedback', LionValidationFeedback);

View file

@ -29,6 +29,7 @@
"stories", "stories",
"test", "test",
"test-helpers", "test-helpers",
"test-suites",
"translations", "translations",
"*.js" "*.js"
], ],

View file

@ -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>|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 || '');
}
}
},
);

View file

@ -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 })}
`,
)}
`;
}
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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<Boolean>}
*/
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>|() => 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>|() => 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.

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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
},
);

View file

@ -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);
});
}

View file

@ -0,0 +1,3 @@
export function pascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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');
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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}</${tag}>`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}>
</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
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}</${tag}>
`);
await el.feedbackComplete;
expect(spy.args[0][0]).to.eql({
validatorParams: 4,
modelValue: 'cat',
formControl: el,
fieldName: 'myFieldViaCfg',
});
});
});
}

File diff suppressed because it is too large Load diff

View file

@ -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;
});
});

View file

@ -0,0 +1,3 @@
import { runFeedbackMixinSuite } from '../test-suites/FeedbackMixin.suite.js';
runFeedbackMixinSuite();

View file

@ -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;
});
});

View file

@ -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;
});
});

View file

@ -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"></${tag}>`);
// 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'}"></${tag}>`);
// 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'}"></${tag}>`);
expect(el2.derived).to.equal('ainit-b');
const el3 = await fixture(html`<${tag} .propB="${'b'}"></${tag}>`);
expect(el3.derived).to.equal('init-ab');
const el4 = await fixture(html`<${tag} .propA=${'a'} .propB="${'b'}"></${tag}>`);
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}></${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'}"></${tag}>`);
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}></${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);
});
});
});

View file

@ -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"}');
});
});

View file

@ -1,3 +1,4 @@
<<<<<<< HEAD
/* eslint-disable no-unused-vars, no-param-reassign */ /* eslint-disable no-unused-vars, no-param-reassign */
import { expect, fixture, html, unsafeStatic, defineCE, aTimeout } from '@open-wc/testing'; import { expect, fixture, html, unsafeStatic, defineCE, aTimeout } from '@open-wc/testing';
import sinon from 'sinon'; 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

View file

@ -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}</${tag}>
`);
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;
});
});

View file

@ -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);
});
});

View file

@ -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`
<lion-validation-feedback></lion-validation-feedback>
`,
);
expect(el).shadowDom.to.equal('');
el.feedbackData = [{ message: 'hello', type: 'error', validator: new AlwaysInvalid() }];
await el.updateComplete;
expect(el).shadowDom.to.equal('hello');
});
});

View file

@ -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;
});
});
});