lion/packages/validate/src/ValidateMixin.js

588 lines
20 KiB
JavaScript

/* eslint-disable class-methods-use-this, camelcase, no-param-reassign */
import { dedupeMixin, SlotMixin } from '@lion/core';
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
import { CssClassMixin } from '@lion/core/src/CssClassMixin.js';
import { localize, LocalizeMixin } from '@lion/localize';
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 */
export const ValidateMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow, max-len
class ValidateMixin extends CssClassMixin(ObserverMixin(LocalizeMixin(SlotMixin(superclass)))) {
/* * * * * * * * * *
Configuration */
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':
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');
default:
throw new Error(`Unknown locale: ${locale}`);
}
},
},
...super.localizeNamespaces,
];
}
static get properties() {
return {
...super.properties,
/**
* List of validators that should set the input to invalid
*/
errorValidators: {
type: Array,
},
error: {
type: Object,
},
errorState: {
type: Boolean,
nonEmptyToClass: 'state-error',
},
errorShow: {
type: Boolean,
nonEmptyToClass: 'state-error-show',
},
warningValidators: {
type: Object,
},
warning: {
type: Object,
},
warningState: {
type: Boolean,
nonEmptyToClass: 'state-warning',
},
warningShow: {
type: Boolean,
nonEmptyToClass: 'state-warning-show',
},
infoValidators: {
type: Object,
},
info: {
type: Object,
},
infoState: {
type: Boolean,
nonEmptyToClass: 'state-info',
},
infoShow: {
type: Boolean,
nonEmptyToClass: 'state-info-show',
},
successValidators: {
type: Object,
},
success: {
type: Object,
},
successState: {
type: Boolean,
nonEmptyToClass: 'state-success',
},
successShow: {
type: Boolean,
nonEmptyToClass: 'state-success-show',
},
invalid: {
type: Boolean,
nonEmptyToClass: 'state-invalid',
},
message: {
type: Boolean,
},
defaultSuccessFeedback: {
type: Boolean,
},
/**
* The currently displayed message(s)
*/
_validationMessage: {
type: String,
},
};
}
static get asyncObservers() {
return {
...super.asyncObservers,
// TODO: consider adding 'touched', 'dirty', 'submitted', 'prefilled' on LionFieldFundament
// level, since ValidateMixin doesn't have a direct dependency on interactionState
_createMessageAndRenderFeedback: [
'error',
'warning',
'info',
'success',
'touched',
'dirty',
'submitted',
'prefilled',
'label',
],
_onErrorShowChangedAsync: ['errorShow'],
};
}
static get syncObservers() {
return {
...super.syncObservers,
validate: [
'errorValidators',
'warningValidators',
'infoValidators',
'successValidators',
'modelValue',
],
_onErrorChanged: ['error'],
_onWarningChanged: ['warning'],
_onInfoChanged: ['info'],
_onSuccessChanged: ['success'],
_onErrorStateChanged: ['errorState'],
_onWarningStateChanged: ['warningState'],
_onInfoStateChanged: ['infoState'],
_onSuccessStateChanged: ['successState'],
};
}
static get validationTypes() {
return ['error', 'warning', 'info', 'success'];
}
get _feedbackElement() {
return (this.$$slot && this.$$slot('feedback')) || this.querySelector('[slot="feedback"]');
}
getFieldName(validatorParams) {
const label =
this.label || (this.$$slot && this.$$slot('label') && this.$$slot('label').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 <lion-component>
if (typeof this._feedbackElement.renderFeedback === 'function') {
this._feedbackElement.renderFeedback(this.getValidationStates(), this.message, details);
} else {
this.renderFeedback(this.getValidationStates(), this.message, details);
}
}
}
_onErrorChanged(newValues, oldValues) {
if (!this.constructor._objectEquals(newValues.error, oldValues.error)) {
this.dispatchEvent(new CustomEvent('error-changed', { bubbles: true, composed: true }));
}
}
_onWarningChanged(newValues, oldValues) {
if (!this.constructor._objectEquals(newValues.warning, oldValues.warning)) {
this.dispatchEvent(new CustomEvent('warning-changed', { bubbles: true, composed: true }));
}
}
_onInfoChanged(newValues, oldValues) {
if (!this.constructor._objectEquals(newValues.info, oldValues.info)) {
this.dispatchEvent(new CustomEvent('info-changed', { bubbles: true, composed: true }));
}
}
_onSuccessChanged(newValues, oldValues) {
if (!this.constructor._objectEquals(newValues.success, 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
*/
renderFeedback() {
if (this._feedbackElement) {
this._feedbackElement.textContent = this._validationMessage;
}
}
_onErrorShowChangedAsync({ errorShow }) {
// Screen reader output should be in sync with visibility of error messages
if (this.inputElement) {
this.inputElement.setAttribute('aria-invalid', errorShow);
// TODO: test and see if needed for a11y
// this.inputElement.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) return;
this.__oldValidationStates = this.getValidationStates();
this.constructor.validationTypes.forEach(type => {
this.validateType(type);
});
this.dispatchEvent(new CustomEvent('validation-done', { bubbles: true, composed: true }));
}
/**
* 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}]]}',
);
}
}
let result = {};
if (resultList.length > 0) {
result = {
list: resultList, // TODO: maybe call this details?
};
// <lion-form> 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[`${type}State`] = resultList.length > 0;
this[type] = result;
}
getValidatorsForType(type) {
if (this.defaultSuccessFeedback && type === 'success') {
return [[randomOk]].concat(this.successValidators || []);
}
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) {
return v === null || typeof v === 'undefined' || v === '';
}
},
);