/**
* @namespace Validations
* @memberOf Model
*/
/**
* Supported ion-input element types
* @memberOf Validations
*/
const ION_TYPES = {
EMAIL: "email",
NUMBER: "number",
TEXT: "text",
DATE: "date"
}
/**
* Supported ion-input element sub-types (under the {@link ION_CONST.name_key})
* @memberOf Validations
*/
const SUB_TYPES = {
TIN: "tin"
}
/**
* @memberOf Validations
*/
const QUERY_ROOTS = {
controller: "controller",
parent: "parent",
self: "self"
}
/**
* Html attribute name constants
*
* mostly straightforward with the notable exceptions:
* - {@link ION_CONST.error.append} variable append strategy - que root of the css query
* - {@link ION_CONST.error.queries}:
* - {@link ION_CONST.error.queries.query} the media query that while be made via {@link HTMLElement#querySelectorAll}
* - {@link ION_CONST.error.queries.variables} variables that will be set/unset:
* the keys will be concatenated with '--' eg: key => element.style.setProperty('--' + key, variables[key].set)
*
* The placeholder ${name} can be used to mark the field's name
* @memberOf Validations
*/
const ION_CONST = {
name_key: "name",
type_key: "type",
required_key: "required",
max_length: "maxlength",
min_length: "minlength",
max_value: "max",
min_value: "min",
input_tag: "ion-input",
error: {
queries: [
{
query: "ion-input",
root: "parent",
variables: [
{
variable: "--color",
set: "var(--ion-color-danger)",
unset: "var(--ion-color)"
}
]
},
{
query: "",
root: "parent",
variables: [
{
variable: "--border-color",
set: "var(--ion-color-danger)",
unset: ""
}
]
}
]
}
}
/**
* Maps prop names to their custom validation
* @param {string} prop
* @param {*} value
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
*/
const propToError = function(prop, value){
switch (prop){
case SUB_TYPES.TIN:
return tinHasErrors(value);
default:
break;
}
}
/**
* Does the match between the Browser's Validity state and the validators/type
* @type {{tooShort: string, typeMismatch: string, stepMismatch: string, rangeOverFlow: string, badInput: undefined, customError: undefined, tooLong: string, patternMismatch: string, rangeUnderFlow: string, valueMissing: string}}
* @memberOf Validations
*/
const ValidityStateMatcher = {
patternMismatch: "pattern",
rangeOverFlow: "max",
rangeUnderFlow: "min",
stepMismatch: "step",
tooLong: "maxlength",
tooShort: "minlength",
typeMismatch: "email|URL",
valueMissing: "required"
}
/**
* Returns
* @return {*}
* @constructor
* @memberOf Validations
*/
const ValidatorRegistry = function(...initial){
const registry = new function(){
const registry = {};
/**
*
* @param validator
* @memberOf ValidatorRegistry
*/
this.register = function(...validator){
validator.forEach(v => {
const instance = new v();
registry[instance.name] = v;
});
}
/**
*
* @param name
* @return {*}
* @memberOf ValidatorRegistry
*/
this.getValidator = function(name){
if (!(name in registry))
return;
return registry[name];
}
/**
* does the matching between the fields validity params and the field's properties (type/subtype)
* @param [validityState]
* @return {*}
* @memberOf ValidatorRegistry
*/
this.matchValidityState = function(validityState = ValidityStateMatcher){
if (typeof validityState === 'string'){
if (!(validityState in ValidityStateMatcher))
return;
return ValidityStateMatcher[validityState];
} else {
const result = {};
for(let prop in validityState)
if (prop in ValidityStateMatcher)
result[ValidityStateMatcher[prop]] = validityState[prop];
return result;
}
}
}()
registry.register(...initial);
return registry;
}
/**
* Handles validations
* @class Validator
* @abstract
* @memberOf Validations
*/
class Validator {
/**
* @param {string} name validator name. Should match the type -> subtype of the field
* @param {string} [errorMessage] should always have a default message
* @constructor
*/
constructor(name, errorMessage= "Child classes must implement this"){
this.name = name;
this.errorMessage = errorMessage;
}
/**
* returns the error message, or nothing if is valid
* @param value the value
* @param [args] optional others args
* @return {string | undefined} errors or nothing
*/
hasErrors(value, ...args){
return this.errorMessage;
}
}
/**
* Validates a pattern
* @param {string} text
* @param {RegExp} pattern in the '//' notation
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
* @return {string | undefined}
*/
const patternHasErrors = function(text, pattern){
if (!text) return;
if (!pattern.test(text))
return "Field does not match pattern";
}
/**
* Handles Pattern validations
* @class PatternValidator
* @extends Validator
* @memberOf Validations
*/
class PatternValidator extends Validator {
/**
* @param {string} errorMessage
* @constructor
*/
constructor(errorMessage = "Field does not match pattern") {
super("pattern", errorMessage);
}
/**
* returns the error message, or nothing if is valid
* @param value the value
* @param pattern the pattern to validate
* @return {string | undefined} the errors or nothing
*/
hasErrors(value, pattern){
return patternHasErrors(value, pattern);
}
}
const emailPattern = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/
/**
* @param {string} email
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
*/
const emailHasErrors = function(email){
if (patternHasErrors(email, emailPattern))
return "Invalid email";
}
/**
* Handles email validations
* @class EmailValidator
* @extends Validator
* @memberOf Validations
*/
class EmailValidator extends Validator {
/**
* @param {string} errorMessage
* @constructor
*/
constructor(errorMessage = "That is not a valid email") {
super("email", errorMessage);
}
/**
* returns the error message, or nothing if is valid
* @param value the value
* @return {string | undefined} the errors or nothing
*/
hasErrors(value){
return patternHasErrors(value, emailPattern);
}
}
/**
* Validates a tin number
* @param {string|number} tin
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
*/
const tinHasErrors = function(tin){
if (!tin) return;
tin = tin + '';
if (patternHasErrors(tin,))
return "Not a valid Tin";
}
/**
* Handles email validations
* @class TinValidator
* @extends Validator
* @memberOf Validations
*/
class TinValidator extends Validator {
/**
* @param {string} errorMessage
* @constructor
*/
constructor(errorMessage = "That is not a valid Tin") {
super("tin", errorMessage);
}
/**
* returns the error message, or nothing if is valid
* @param value the value
* @return {string | undefined} the errors or nothing
*/
hasErrors(value){
return patternHasErrors(value, /^\d{9}$/);
}
}
/**
* Validates a number Field (only integers supported)
* @param {number} value
* @param props
* @memberOf Validations
*/
const numberHasErrors = function(value, props){
if (props[ION_CONST.name_key] === SUB_TYPES.TIN)
return tinHasErrors(value);
let {max, min} = props;
if (value > max)
return `The maximum is ${max}`;
if (value < min)
return `The minimum is ${min}`;
}
/**
* Validates a date value
* @param {Date} date
* @param props
* @memberOf Validations
*/
const dateHasErrors = function(date, props){
throw new Error("Not implemented date validation");
}
/**
* Validates a text value
* @param {string} text
* @param props
* @memberOf Validations
*/
const textHasErrors = function(text, props){
if (props[ION_CONST.name_key] === SUB_TYPES.TIN)
return tinHasErrors(text);
}
/**
* parses the numeric values
* @param props
* @memberOf Validations
*/
const parseNumeric = function(props){
let prop;
try{
for (prop in props)
if (props.hasOwnProperty(prop) && props[prop])
if ([ION_CONST.max_length, ION_CONST.max_value, ION_CONST.min_length, ION_CONST.min_value].indexOf(prop) !== -1)
props[prop] = parseInt(props[prop]);
} catch (e){
throw new Error(`Could not parse numeric validations attributes for field ${props.name} prop: ${prop}`);
}
return props;
}
/**
* Parses the supported attributes in the element
* @param {HTMLElement} element
* @return the object of existing supported attributes
* @memberOf Validations
*/
const getValidationAttributes = function(element){
return {
type: element[ION_CONST.type_key],
name: element[ION_CONST.name_key],
required: element[ION_CONST.required_key],
max: element[ION_CONST.max_value],
maxlength: element[ION_CONST.max_length],
min: element[ION_CONST.min_value],
minlength: element[ION_CONST.min_length]
};
}
/**
* Validates a ion-input element for required & max/min length.
* @param {HTMLElement} element
* @param {object} props
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
*/
const hasRequiredAndLengthErrors = function(element, props){
let {required, maxLength, minLength} = props;
let value = element.value;
value = value && typeof value === 'string' ? value.trim() : value;
if (required && !value)
return "Field is required";
if (!value) return;
if (minLength && value.length < minLength)
return `The minimum length is ${minLength}`;
if (maxLength && value.length > maxLength)
return `The maximum length is ${minLength}`;
}
/**
*
* @param props
* @param prefix
* @return {boolean}
* @memberOf Validations
*/
const testInputEligibility = function(props, prefix){
return !(!props[ION_CONST.name_key] || !props[ION_CONST.type_key] || props[ION_CONST.name_key].indexOf(prefix) === -1);
}
/**
* Test a specific type of Ionic input field for errors
*
* should (+/-) match the ion-input type property
*
* supported types:
* - email;
* - tin
* - text
* - number
*
* @param {HTMLElement} element the ion-input field
* @param {string} prefix the prefix for the ion-input to be validated
* @memberOf Validations
*/
const hasIonErrors = function(element, prefix){
let props = getValidationAttributes(element);
if (!testInputEligibility(props, prefix))
throw new Error(`input field ${element} with props ${props} does not meet criteria for validation`);
props[ION_CONST.name_key] = props[ION_CONST.name_key].substring(prefix.length);
let errors = hasRequiredAndLengthErrors(element, props);
if (errors)
return errors;
let value = element.value;
switch (props[ION_CONST.type_key]){
case ION_TYPES.EMAIL:
errors = emailHasErrors(value);
break;
case ION_TYPES.DATE:
errors = dateHasErrors(value, props);
break;
case ION_TYPES.NUMBER:
props = parseNumeric(props);
errors = numberHasErrors(value, props);
break;
case ION_TYPES.TEXT:
errors = textHasErrors(value, props);
break;
default:
errors = undefined;
}
return errors;
}
/**
* Until I get 2way data binding to work on ionic components, this solves it.
*
* It validates the fields via their ion-input supported properties for easy integration if they ever work natively
*
* If the input's value has changed, an event called 'input-has-changed' with the input name as data
*
* @param {WebcController} controller
* @param {HTMLElement} element the ion-input element
* @param {string} prefix prefix to the name of the input elements
* @param {boolean} [force] defaults to false. if true ignores if the value changed or not
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
*/
const updateModelAndGetErrors = function(controller, element, prefix, force){
force = !!force || false;
if (!controller.model)
return;
let name = element.name.substring(prefix.length);
if (typeof controller.model[name] === 'object') {
let valueChanged = (controller.model[name].value === undefined && !!element.value)
|| (!!controller.model[name].value && controller.model[name].value !== element.value);
controller.model[name].value = element.value;
if (valueChanged || force){
const hasErrors = hasIonErrors(element, prefix);
controller.model[name].error = hasErrors;
updateStyleVariables(controller, element, hasErrors);
controller.send('input-has-changed', name);
return hasErrors;
}
return controller.model[name].error;
}
}
/**
* Manages the inclusion/exclusion of the error variables according to {@link ION_CONST#error#variables} in the element according to the selected {@link ION_CONST#error#append}
* @param {WebcController} controller
* @param {HTMLElement} element
* @param {string} hasErrors
* @memberOf Validations
*/
const updateStyleVariables = function(controller, element, hasErrors){
let el, selected, q;
const getRoot = function(root) {
let elem;
switch (root) {
case QUERY_ROOTS.parent:
elem = element.parentElement;
break;
case QUERY_ROOTS.self:
elem = element;
break;
case QUERY_ROOTS.controller:
elem = controller.element;
break;
default:
throw new Error("Unsupported Error style strategy");
}
return elem;
}
const queries = ION_CONST.error.queries;
queries.forEach(query => {
q = query.query.replace('${name}', element.name);
el = getRoot(query.root);
selected = q ? el.querySelectorAll(q) : [el];
selected.forEach(s => {
query.variables.forEach(v => {
s.style.setProperty(v.variable, hasErrors ? v.set : v.unset)
});
});
});
}
/**
* iterates through all supported inputs and calls {@link updateModelAndGetErrors} on each.
*
* sends controller validation event
* @param {WebcController} controller
* @param {string} prefix
* @return {boolean} if there are any errors in the model
* @param {boolean} force (Decides if forces the validation to happen even if fields havent changed)
* @memberOf Validations
*/
const controllerHasErrors = function(controller, prefix, force){
let inputs = controller.element.querySelectorAll(`${ION_CONST.input_tag}[name^="${prefix}"]`);
let errors = [];
let error;
inputs.forEach(el => {
error = updateModelAndGetErrors(controller, el, prefix, force);
if (error)
errors.push(error);
});
let hasErrors = errors.length > 0;
controller.send(hasErrors ? 'ion-model-is-invalid' : 'ion-model-is-valid');
return hasErrors;
}
/**
* When using ionic input components, this binds the controller for validation purposes.
*
* Inputs to be eligible for validation need to be named '${prefix}${propName}' where the propName must
* match the type param in {@link hasErrors} via {@link updateModelAndGetErrors}
*
* Gives access to the validateIonic method on the controller via:
* <pre>
* controller.hasErrors();
* </pre>
* (returns true or false)
*
* where all the inputs are validated
*
* @param {WebcController} controller
* @param {function()} [onValidModel] the function to be called when the whole Controller model is valid
* @param {function()} [onInvalidModel] the function to be called when any part of the model is invalid
* @param {string} [prefix] the prefix for the ion-input to be validated. defaults to 'input-'
* @memberOf Validations
*/
const bindIonicValidation = function(controller, onValidModel, onInvalidModel, prefix){
if (typeof onInvalidModel === 'string' || !onInvalidModel){
prefix = onInvalidModel
onInvalidModel = () => {
const submitButton = controller.element.querySelector('ion-button[type="submit"]');
if (submitButton)
submitButton.disabled = true;
}
}
if (typeof onValidModel === 'string' || !onValidModel){
prefix = onValidModel
onValidModel = () => {
const submitButton = controller.element.querySelector('ion-button[type="submit"]');
if (submitButton)
submitButton.disabled = false;
}
}
prefix = prefix || 'input-';
controller.on('ionChange', (evt) => {
evt.preventDefault();
evt.stopImmediatePropagation();
let element = evt.target;
if (!element.name) return;
let errors = updateModelAndGetErrors(controller, element, prefix);
if (errors) // one fails, all fail
controller.send('ion-model-is-invalid');
else // Now we have to check all of them
controllerHasErrors(controller, prefix);
});
controller.hasErrors = (force) => controllerHasErrors(controller, prefix, force);
controller.on('ion-model-is-valid', (evt) => {
evt.preventDefault();
evt.stopImmediatePropagation();
if (onValidModel)
onValidModel.apply(controller);
});
controller.on('ion-model-is-invalid', (evt) => {
evt.preventDefault();
evt.stopImmediatePropagation();
if (onInvalidModel)
onInvalidModel.apply(controller);
});
controller.on('input-has-changed', _handleErrorElement.bind(controller));
}
/**
*
* @param evt
* @private
* @memberOf Validations
*/
const _handleErrorElement = function(evt){
let name = evt.detail;
let attributes = this.model.toObject()[name];
let errorEl = this.element.querySelector(`ion-note[name="note-${name}"]`);
if (attributes.error){
if (errorEl){
errorEl.innerHTML = attributes.error;
} else {
errorEl = document.createElement('ion-note');
errorEl.setAttribute('position', 'stacked');
errorEl.setAttribute('slot', 'end');
errorEl.setAttribute('color', 'danger');
errorEl.setAttribute('name', `note-${name}`)
errorEl.innerHTML = attributes.error;
let htmlEl = this.element.querySelector(`ion-item ion-input[name="input-${name}"]`);
htmlEl.insertAdjacentElement('afterend', errorEl);
}
} else if (errorEl) {
errorEl.remove();
}
}
/**
* Validates a Model element according to prop names
* *Does not validate 'required' or more complex attributes yet*
* TODO use annotations to accomplish that
* @returns {string|undefined} undefined if ok, the error otherwise
* @memberOf Validations
*/
const modelHasErrors = function(model){
let error;
for (let prop in model)
if (model.hasOwnProperty(prop)){
if (prop in Object.values(ION_TYPES) || prop in Object.values(SUB_TYPES))
error = propToError(prop, model[prop]);
if (error)
return error;
}
}
/**
* Provides the implementation for the Model to be validatable alongside Ionic components
* via the {@link hasErrors} method
* @interface
* @memberOf Validations
*/
class Validatable{
/**
* @see {modelHasErrors}
*/
hasErrors(){
return modelHasErrors(this);
}
}
module.exports = {
Validatable,
Validators: {
Validator: Validator,
Validators: {
TinValidator: TinValidator,
EmailValidator: EmailValidator,
PatternValidator: PatternValidator
},
ValidityStateMatcher: ValidityStateMatcher,
Registry: ValidatorRegistry(TinValidator, EmailValidator, PatternValidator)
},
bindIonicValidation,
emailHasErrors,
tinHasErrors,
textHasErrors,
numberHasErrors,
hasIonErrors
};
Source