feat(form-core): allow enums as outcome of a Validator

This commit is contained in:
Thijs Louisse 2022-03-16 10:03:34 +01:00
parent e457ce73bb
commit 7016a150dc
5 changed files with 341 additions and 183 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': minor
---
Validation: allow enums as outcome of a Validator

View file

@ -17,6 +17,10 @@ import { FormControlMixin } from '../FormControlMixin.js';
/** /**
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidateMixin} ValidateMixin * @typedef {import('../../types/validate/ValidateMixinTypes').ValidateMixin} ValidateMixin
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidationType} ValidationType * @typedef {import('../../types/validate/ValidateMixinTypes').ValidationType} ValidationType
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidateHost} ValidateHost
* @typedef {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} ValidateHostConstructor
* @typedef {{validator:Validator; outcome:boolean|string}} ValidationResultEntry
* @typedef {{[type:string]: {[validatorName:string]:boolean|string}}} ValidationStates
*/ */
/** /**
@ -159,7 +163,7 @@ export const ValidateMixinImplementation = superclass =>
*/ */
this.showsFeedbackFor = []; this.showsFeedbackFor = [];
// TODO: [v1] make this fully private (preifix __)? // TODO: [v1] make this fully private (prefix __)?
/** /**
* A temporary storage to transition from hasFeedbackFor to showsFeedbackFor * A temporary storage to transition from hasFeedbackFor to showsFeedbackFor
* @type {ValidationType[]} * @type {ValidationType[]}
@ -171,7 +175,7 @@ export const ValidateMixinImplementation = superclass =>
/** /**
* The outcome of a validation 'round'. Keyed by ValidationType and Validator name * The outcome of a validation 'round'. Keyed by ValidationType and Validator name
* @readOnly * @readOnly
* @type {Object.<string, Object.<string, boolean>>} * @type {ValidationStates}
*/ */
this.validationStates = {}; this.validationStates = {};
@ -206,44 +210,45 @@ export const ValidateMixinImplementation = superclass =>
/** /**
* The amount of feedback messages that will visible in LionValidationFeedback * The amount of feedback messages that will visible in LionValidationFeedback
* @configurable
* @protected * @protected
*/ */
this._visibleMessagesAmount = 1; this._visibleMessagesAmount = 1;
/** /**
* @type {Validator[]} * @type {ValidationResultEntry[]}
* @private * @private
*/ */
this.__syncValidationResult = []; this.__syncValidationResult = [];
/** /**
* @type {Validator[]} * @type {ValidationResultEntry[]}
* @private * @private
*/ */
this.__asyncValidationResult = []; this.__asyncValidationResult = [];
/** /**
* Aggregated result from sync Validators, async Validators and ResultValidators * Aggregated result from sync Validators, async Validators and ResultValidators
* @type {Validator[]} * @type {ValidationResultEntry[]}
* @private * @private
*/ */
this.__validationResult = []; this.__validationResult = [];
/** /**
* @type {Validator[]} * @type {ValidationResultEntry[]}
* @private * @private
*/ */
this.__prevValidationResult = []; this.__prevValidationResult = [];
/** /**
* @type {Validator[]} * @type {ValidationResultEntry[]}
* @private * @private
*/ */
this.__prevShownValidationResult = []; this.__prevShownValidationResult = [];
/** /**
* The updated children validity affects the validity of the parent. Helper to recompute * The updated children validity affects the validity of the parent. Helper to recompute
* validatity of parent FormGroup * validity of parent FormGroup
* @private * @private
*/ */
this.__childModelValueChanged = false; this.__childModelValueChanged = false;
@ -337,7 +342,7 @@ export const ValidateMixinImplementation = superclass =>
* Triggered by: * Triggered by:
* - modelValue change * - modelValue change
* - change in the 'validators' array * - change in the 'validators' array
* - change in the config of an individual Validator * - change in the config of an individual Validator
* *
* Three situations are handled: * Three situations are handled:
* - a1) the FormControl is empty: further execution is halted. When the Required Validator * - a1) the FormControl is empty: further execution is halted. When the Required Validator
@ -384,6 +389,14 @@ export const ValidateMixinImplementation = superclass =>
* @desc step a1-3 + b (as explained in `validate()`) * @desc step a1-3 + b (as explained in `validate()`)
*/ */
async __executeValidators() { async __executeValidators() {
/**
* Allows Application Developer to wait for (async) validation
* @example
* ```js
* await el.validateComplete;
* ```
* @type {Promise<boolean>}
*/
this.validateComplete = new Promise(resolve => { this.validateComplete = new Promise(resolve => {
this.__validateCompleteResolve = resolve; this.__validateCompleteResolve = resolve;
}); });
@ -410,7 +423,7 @@ export const ValidateMixinImplementation = superclass =>
const isEmpty = this.__isEmpty(value); const isEmpty = this.__isEmpty(value);
if (isEmpty) { if (isEmpty) {
if (requiredValidator) { if (requiredValidator) {
this.__syncValidationResult = [requiredValidator]; this.__syncValidationResult = [{ validator: requiredValidator, outcome: true }];
} }
this.__finishValidation({ source: 'sync' }); this.__finishValidation({ source: 'sync' });
return; return;
@ -451,9 +464,12 @@ export const ValidateMixinImplementation = superclass =>
*/ */
__executeSyncValidators(syncValidators, value, { hasAsync }) { __executeSyncValidators(syncValidators, value, { hasAsync }) {
if (syncValidators.length) { if (syncValidators.length) {
this.__syncValidationResult = syncValidators.filter(v => this.__syncValidationResult = syncValidators
v.execute(value, v.param, { node: this }), .map(v => ({
); validator: v,
outcome: /** @type {boolean|string} */ (v.execute(value, v.param, { node: this })),
}))
.filter(v => Boolean(v.outcome));
} }
this.__finishValidation({ source: 'sync', hasAsync }); this.__finishValidation({ source: 'sync', hasAsync });
} }
@ -468,10 +484,15 @@ export const ValidateMixinImplementation = superclass =>
if (asyncValidators.length) { if (asyncValidators.length) {
this.isPending = true; this.isPending = true;
const resultPromises = asyncValidators.map(v => v.execute(value, v.param, { node: this })); const resultPromises = asyncValidators.map(v => v.execute(value, v.param, { node: this }));
const booleanResults = await Promise.all(resultPromises); const asyncExecutionResults = await Promise.all(resultPromises);
this.__asyncValidationResult = booleanResults
.map((r, i) => asyncValidators[i]) // Create an array of Validators this.__asyncValidationResult = asyncExecutionResults
.filter((v, i) => booleanResults[i]); // Only leave the ones returning true .map((r, i) => ({
validator: asyncValidators[i],
outcome: /** @type {boolean|string} */ (asyncExecutionResults[i]),
}))
.filter(v => Boolean(v.outcome));
this.__finishValidation({ source: 'async' }); this.__finishValidation({ source: 'async' });
this.isPending = false; this.isPending = false;
} }
@ -479,7 +500,7 @@ export const ValidateMixinImplementation = superclass =>
/** /**
* step b (as explained in `validate()`), called by __finishValidation * step b (as explained in `validate()`), called by __finishValidation
* @param {Validator[]} regularValidationResult result of steps 1-3 * @param {{validator:Validator; outcome: boolean|string;}[]} regularValidationResult result of steps 1-3
* @private * @private
*/ */
__executeResultValidators(regularValidationResult) { __executeResultValidators(regularValidationResult) {
@ -490,13 +511,21 @@ export const ValidateMixinImplementation = superclass =>
}) })
); );
return resultValidators.filter(v => // Map everything to Validator[] for backwards compatibility
v.executeOnResults({ return resultValidators
regularValidationResult, .map(v => ({
prevValidationResult: this.__prevValidationResult, validator: v,
prevShownValidationResult: this.__prevShownValidationResult, outcome: /** @type {boolean|string} */ (
}), v.executeOnResults({
); regularValidationResult: regularValidationResult.map(entry => entry.validator),
prevValidationResult: this.__prevValidationResult.map(entry => entry.validator),
prevShownValidationResult: this.__prevShownValidationResult.map(
entry => entry.validator,
),
})
),
}))
.filter(v => Boolean(v.outcome));
} }
/** /**
@ -512,35 +541,32 @@ export const ValidateMixinImplementation = superclass =>
const resultOutCome = this.__executeResultValidators(syncAndAsyncOutcome); const resultOutCome = this.__executeResultValidators(syncAndAsyncOutcome);
this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome]; this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome];
// this._storeResultsOnInstance(this.__validationResult);
const ctor = const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
/** @type {Object.<string, Object.<string, boolean>>} */ /** @type {ValidationStates} */
const validationStates = ctor.validationTypes.reduce( const validationStates = ctor.validationTypes.reduce(
(acc, type) => ({ ...acc, [type]: {} }), (acc, type) => ({ ...acc, [type]: {} }),
{}, {},
); );
this.__validationResult.forEach(v => { this.__validationResult.forEach(({ validator, outcome }) => {
if (!validationStates[v.type]) { if (!validationStates[validator.type]) {
validationStates[v.type] = {}; validationStates[validator.type] = {};
} }
const vCtor = /** @type {typeof Validator} */ (v.constructor); const vCtor = /** @type {typeof Validator} */ (validator.constructor);
validationStates[v.type][vCtor.validatorName] = true; validationStates[validator.type][vCtor.validatorName] = outcome;
}); });
this.validationStates = validationStates; this.validationStates = validationStates;
this.hasFeedbackFor = [...new Set(this.__validationResult.map(v => v.type))]; this.hasFeedbackFor = [
...new Set(this.__validationResult.map(({ validator }) => validator.type)),
];
/** private event that should be listened to by LionFieldSet */ /** private event that should be listened to by LionFieldSet */
this.dispatchEvent(new Event('validate-performed', { bubbles: true })); this.dispatchEvent(new Event('validate-performed', { bubbles: true }));
if (source === 'async' || !hasAsync) { if (source === 'async' || !hasAsync) {
if (this.__validateCompleteResolve) { if (this.__validateCompleteResolve) {
// @ts-ignore [allow-private] this.__validateCompleteResolve(true);
this.__validateCompleteResolve();
} }
} }
} }
@ -587,10 +613,7 @@ export const ValidateMixinImplementation = superclass =>
console.error(errorMessage, this); console.error(errorMessage, this);
throw new Error(errorMessage); throw new Error(errorMessage);
} }
const ctor = const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
if (ctor.validationTypes.indexOf(v.type) === -1) { if (ctor.validationTypes.indexOf(v.type) === -1) {
const vCtor = /** @type {typeof Validator} */ (v.constructor); const vCtor = /** @type {typeof Validator} */ (v.constructor);
// throws in constructor are not visible to end user so we do both // throws in constructor are not visible to end user so we do both
@ -640,14 +663,14 @@ export const ValidateMixinImplementation = superclass =>
*/ */
/** /**
* @param {Validator[]} validators list of objects having a .getMessage method * @param {ValidationResultEntry[]} validationResults list of objects having a .getMessage method
* @return {Promise.<FeedbackMessage[]>} * @return {Promise.<FeedbackMessage[]>}
* @private * @private
*/ */
async __getFeedbackMessages(validators) { async __getFeedbackMessages(validationResults) {
let fieldName = await this.fieldName; let fieldName = await this.fieldName;
return Promise.all( return Promise.all(
validators.map(async validator => { validationResults.map(async ({ validator, outcome }) => {
if (validator.config.fieldName) { if (validator.config.fieldName) {
fieldName = await validator.config.fieldName; fieldName = await validator.config.fieldName;
} }
@ -656,6 +679,7 @@ export const ValidateMixinImplementation = superclass =>
modelValue: this.modelValue, modelValue: this.modelValue,
formControl: this, formControl: this,
fieldName, fieldName,
outcome,
}); });
return { message, type: validator.type, validator }; return { message, type: validator.type, validator };
}), }),
@ -690,10 +714,19 @@ export const ValidateMixinImplementation = superclass =>
if (this.showsFeedbackFor.length > 0) { if (this.showsFeedbackFor.length > 0) {
this.__feedbackQueue.add(async () => { this.__feedbackQueue.add(async () => {
/** @type {Validator[]} */ /** @type {Validator[]} */
this.__prioritizedResult = this._prioritizeAndFilterFeedback({ const prioritizedValidators = this._prioritizeAndFilterFeedback({
validationResult: this.__validationResult, validationResult: this.__validationResult.map(entry => entry.validator),
}); });
this.__prioritizedResult = prioritizedValidators
.map(v => {
const found = /** @type {ValidationResultEntry} */ (
this.__validationResult.find(r => v === r.validator)
);
return found;
})
.filter(Boolean);
if (this.__prioritizedResult.length > 0) { if (this.__prioritizedResult.length > 0) {
this.__prevShownValidationResult = this.__prioritizedResult; this.__prevShownValidationResult = this.__prioritizedResult;
} }
@ -732,12 +765,12 @@ export const ValidateMixinImplementation = superclass =>
} }
/** /**
* Allows the end user to specify when a feedback message should be shown * Allows the Application Developer to specify when a feedback message should be shown
* @example * @example
* ```js * ```js
* feedbackCondition(type, meta, defaultCondition) { * feedbackCondition(type, meta, defaultCondition) {
* if (type === 'info') { * if (type === 'info') {
* return return; * return true;
* } else if (type === 'prefilledOnly') { * } else if (type === 'prefilledOnly') {
* return meta.prefilled; * return meta.prefilled;
* } * }
@ -775,7 +808,9 @@ export const ValidateMixinImplementation = superclass =>
); );
} }
/** @param {import('@lion/core').PropertyValues} changedProperties */ /**
* @param {import('@lion/core').PropertyValues} changedProperties
*/
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
@ -783,10 +818,7 @@ export const ValidateMixinImplementation = superclass =>
changedProperties.has('shouldShowFeedbackFor') || changedProperties.has('shouldShowFeedbackFor') ||
changedProperties.has('hasFeedbackFor') changedProperties.has('hasFeedbackFor')
) { ) {
const ctor = const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined // Necessary typecast because types aren't smart enough to understand that we filter out undefined
this.showsFeedbackFor = /** @type {string[]} */ ( this.showsFeedbackFor = /** @type {string[]} */ (
ctor.validationTypes ctor.validationTypes
@ -822,10 +854,7 @@ export const ValidateMixinImplementation = superclass =>
* @protected * @protected
*/ */
_updateShouldShowFeedbackFor() { _updateShouldShowFeedbackFor() {
const ctor = const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined // Necessary typecast because types aren't smart enough to understand that we filter out undefined
const newShouldShowFeedbackFor = /** @type {string[]} */ ( const newShouldShowFeedbackFor = /** @type {string[]} */ (
@ -848,18 +877,15 @@ export const ValidateMixinImplementation = superclass =>
} }
/** /**
* Orders all active validators in this.__validationResult. Can * Orders all active validators in this.__validationResult.
* also filter out occurrences (based on interaction states) * Can also filter out occurrences (based on interaction states)
* @overridable * @overridable
* @param {{ validationResult: Validator[] }} opts * @param {{ validationResult: Validator[] }} opts
* @return {Validator[]} ordered list of Validators with feedback messages visible to the end user * @return {Validator[]} ordered list of Validators with feedback messages visible to the end user
* @protected * @protected
*/ */
_prioritizeAndFilterFeedback({ validationResult }) { _prioritizeAndFilterFeedback({ validationResult }) {
const ctor = const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
const types = ctor.validationTypes; const types = ctor.validationTypes;
// Sort all validators based on the type provided. // Sort all validators based on the type provided.
const res = validationResult const res = validationResult

View file

@ -1,44 +1,65 @@
/** /**
* @typedef {object} MessageData * @typedef {import('./types').FeedbackMessageData} FeedbackMessageData
* @property {*} [MessageData.modelValue] * @typedef {import('./types').ValidatorParam} ValidatorParam
* @property {string} [MessageData.fieldName] * @typedef {import('./types').ValidatorConfig} ValidatorConfig
* @property {HTMLElement} [MessageData.formControl] * @typedef {import('./types').ValidatorOutcome} ValidatorOutcome
* @property {string} [MessageData.type] * @typedef {import('./types').ValidatorName} ValidatorName
* @property {Object.<string,?>} [MessageData.config] * @typedef {import('./types').ValidationType} ValidationType
* @property {string} [MessageData.name] * @typedef {import('../FormControlMixin').FormControlHost} FormControlHost
*/ */
export class Validator { // TODO: support attribute validators like <my-el my-validator=${dynamicParam}></my-el> =>
// register in a ValidateService that is read by Validator and adds these attrs in properties
// object.
// They would become like configurable
// [global attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes)
// for FormControls.
export class Validator extends EventTarget {
/** /**
* * @param {ValidatorParam} [param]
* @param {?} [param] * @param {ValidatorConfig} [config]
* @param {Object.<string,?>} [config]
*/ */
constructor(param, config) { constructor(param, config) {
this.__fakeExtendsEventTarget(); super();
/** @type {?} */ /** @type {ValidatorParam} */
this.__param = param; this.__param = param;
/** @type {ValidatorConfig} */
/** @type {Object.<string,?>} */
this.__config = config || {}; this.__config = config || {};
this.type = (config && config.type) || 'error'; // Default type supported by ValidateMixin /** @type {ValidationType} */
} this.type = config?.type || 'error'; // Default type supported by ValidateMixin
static get validatorName() {
return '';
}
static get async() {
return false;
} }
/** /**
* @desc The function that returns a Boolean * The name under which validation results get registered. For convience and predictability, this
* @param {?} [modelValue] * should always be the same as the constructor name (since it will be obfuscated in js builds,
* @param {?} [param] * we need to provide it separately).
* @param {{}} [config] * @type {ValidatorName}
* @returns {Boolean|Promise<Boolean>} */
static validatorName = '';
/**
* Whether the validator is asynchronous or not. When true., this means execute function returns
* a Promise. This can be handy for:
* - server side calls
* - validations that are dependent on lazy loaded resources (they can be async until the dependency
* is loaded)
* @type {boolean}
*/
static async = false;
/**
* The function that returns a validity outcome. When we need to shpw feedback,
* it should return true, otherwise false. So when an error\info|warning|success message
* needs to be shown, return true. For async Validators, the function canretun a Promise.
* It's also possible to return an enum. Let's say that a phone number can have multiple
* states: 'invalid-country-code' | 'too-long' | 'too-short'
* Those states can be retrieved in the getMessage
* @param {any} modelValue
* @param {ValidatorParam} [param]
* @param {ValidatorConfig} [config]
* @returns {ValidatorOutcome|Promise<ValidatorOutcome>}
*/ */
// eslint-disable-next-line no-unused-vars, class-methods-use-this // eslint-disable-next-line no-unused-vars, class-methods-use-this
execute(modelValue, param, config) { execute(modelValue, param, config) {
@ -51,22 +72,55 @@ export class Validator {
return true; return true;
} }
/**
* The first argument of the constructor, for instance 3 in `new MinLength(3)`. Will
* be stored on Validator instance and passed to `execute` function
* @example
* ```js
* // Store reference to Validator instance
* const myValidatorInstance = new MyValidator(1);
* // Use this instance initially on a FormControl (that uses ValidateMixin)
* render(html`<validatable-element .validators="${[myValidatorInstance]}"></validatable-element>`, document.body);
* // Based on some event, we need to change the param
* myValidatorInstance.param = 2;
* ```
* @property {ValidatorParam}
*/
set param(p) { set param(p) {
this.__param = p; this.__param = p;
if (this.dispatchEvent) { /**
this.dispatchEvent(new Event('param-changed')); * This event is listened for by ValidateMixin. Whenever the validation parameter has
} * changed, the FormControl will revalidate itself
*/
this.dispatchEvent(new Event('param-changed'));
} }
get param() { get param() {
return this.__param; return this.__param;
} }
/**
* The second argument of the constructor, for instance
* `new MinLength(3, {getFeedMessage: async () => 'too long'})`.
* Will be stored on Validator instance and passed to `execute` function.
* @example
* ```js
* // Store reference to Validator instance
* const myValidatorInstance = new MyValidator(1, {getMessage() => 'x'});
* // Use this instance initially on a FormControl (that uses ValidateMixin)
* render(html`<validatable-element .validators="${[myValidatorInstance]}"></validatable-element>`, document.body);
* // Based on some event, we need to change the param
* myValidatorInstance.config = {getMessage() => 'y'};
* ```
* @property {ValidatorConfig}
*/
set config(c) { set config(c) {
this.__config = c; this.__config = c;
if (this.dispatchEvent) { /**
this.dispatchEvent(new Event('config-changed')); * This event is listened for by ValidateMixin. Whenever the validation config has
} * changed, the FormControl will revalidate itself
*/
this.dispatchEvent(new Event('config-changed'));
} }
get config() { get config() {
@ -74,9 +128,29 @@ export class Validator {
} }
/** /**
* @overridable * This is a protected method that usually should not be overridden. It is called by ValidateMixin
* @param {MessageData} [data] * and it gathers data to be passed to getMessage functions found:
* @returns {Promise<string|Node>} * - `this.config.getMessage`, locally provided by consumers of the Validator (overrides global getMessage)
* - `MyValidator.getMessage`, globally provided by creators or consumers of the Validator
*
* Confusion can arise because of similarities with former mentioned methods. In that regard, a
* better name for this function would have been _pepareDataAndCallHighestPrioGetMessage.
* @example
* ```js
* class MyValidator extends Validator {
* // ...
* // 1. globally defined
* static async getMessage() {
* return 'lowest prio, defined globally by Validator author'
* }
* }
* // 2. globally overridden
* MyValidator.getMessage = async() => 'overrides already configured message';
* // 3. locally overridden
* new MyValidator(myParam, { getMessage: async() => 'locally defined, always wins' });
* ```
* @param {Partial<FeedbackMessageData>} [data]
* @returns {Promise<string|Element>}
* @protected * @protected
*/ */
async _getMessage(data) { async _getMessage(data) {
@ -101,9 +175,20 @@ export class Validator {
} }
/** /**
* Called inside Validator.prototype._getMessage (see explanation).
* @example
* ```js
* class MyValidator extends Validator {
* static async getMessage() {
* return 'lowest prio, defined globally by Validator author'
* }
* }
* // globally overridden
* MyValidator.getMessage = async() => 'overrides already configured message';
* ```
* @overridable * @overridable
* @param {MessageData} [data] * @param {Partial<FeedbackMessageData>} [data]
* @returns {Promise<string|Node>} * @returns {Promise<string|Element>}
*/ */
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
static async getMessage(data) { static async getMessage(data) {
@ -111,12 +196,38 @@ export class Validator {
} }
/** /**
* @param {HTMLElement} formControl * Validators are allowed to have knowledge about FormControls.
* In some cases (in case of the Required Validator) we wanted to enhance accessibility by
* adding [aria-required]. Also, it would be possible to write an advanced MinLength
* Validator that adds a .preprocessor that restricts from typing too many characters
* (like the native [minlength] validator).
* Will be called when Validator is added to FormControl.validators.
* @example
* ```js
* onFormControlConnect(formControl) {
* if(formControl.inputNode) {
* inputNode.setAttribute('aria-required', 'true');
* }
* }
*
* ```
* @configurable
* @param {FormControlHost} formControl
*/ */
onFormControlConnect(formControl) {} // eslint-disable-line onFormControlConnect(formControl) {} // eslint-disable-line
/** /**
* @param {HTMLElement} formControl * Also see `onFormControlConnect`.
* Will be called when Validator is removed from FormControl.validators.
* @example
* ```js
* onFormControlDisconnect(formControl) {
* if(formControl.inputNode) {
* inputNode.removeAttribute('aria-required');
* }
* }
* @configurable
* @param {FormControlHost} formControl
*/ */
onFormControlDisconnect(formControl) {} // eslint-disable-line onFormControlDisconnect(formControl) {} // eslint-disable-line
@ -130,41 +241,6 @@ export class Validator {
* - Or, when a webworker was started, its process could be aborted and then restarted. * - Or, when a webworker was started, its process could be aborted and then restarted.
*/ */
abortExecution() {} // eslint-disable-line abortExecution() {} // eslint-disable-line
/**
* @private
*/
__fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
/**
*
* @param {string} type
* @param {EventListener} listener
* @param {Object} [opts]
*/
const delegatedAddEventListener = (type, listener, opts) =>
delegate.addEventListener(type, listener, opts);
/**
* @param {string} type
* @param {EventListener} listener
* @param {Object} [opts]
*/
const delegatedRemoveEventListener = (type, listener, opts) =>
delegate.removeEventListener(type, listener, opts);
/**
* @param {Event|CustomEvent} event
*/
const delegatedDispatchEvent = event => delegate.dispatchEvent(event);
this.addEventListener = delegatedAddEventListener;
this.removeEventListener = delegatedRemoveEventListener;
this.dispatchEvent = delegatedDispatchEvent;
}
} }
// For simplicity, a default validator only handles one state: // For simplicity, a default validator only handles one state:

View file

@ -70,7 +70,7 @@ export function runValidateMixinSuite(customConfig) {
*/ */
describe('Validation initiation', () => { describe('Validation initiation', () => {
it('throws and console.errors if adding not Validator instances to the validators array', async () => { it('throws and console.errors if adding non Validator instances to the validators array', async () => {
// we throw and console error as constructor throw are not visible to the end user // we throw and console error as constructor throw are not visible to the end user
const stub = sinon.stub(console, 'error'); const stub = sinon.stub(console, 'error');
const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`)); const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`));
@ -93,7 +93,7 @@ export function runValidateMixinSuite(customConfig) {
stub.restore(); stub.restore();
}); });
it('throws and console error if adding a not supported Validator type', async () => { it('throws a console error if adding a non supported Validator type', async () => {
// we throw and console error to improve DX // we throw and console error to improve DX
const stub = sinon.stub(console, 'error'); const stub = sinon.stub(console, 'error');
const errorMessage = `This component does not support the validator type "major error" used in "MajorValidator". You may change your validators type or add it to the components "static get validationTypes() {}".`; const errorMessage = `This component does not support the validator type "major error" used in "MajorValidator". You may change your validators type or add it to the components "static get validationTypes() {}".`;
@ -720,7 +720,10 @@ export function runValidateMixinSuite(customConfig) {
// @ts-ignore [allow-private] in test // @ts-ignore [allow-private] in test
const totalValidationResult = el.__validationResult; const totalValidationResult = el.__validationResult;
expect(totalValidationResult).to.eql([resultV, validator]); expect(totalValidationResult).to.eql([
{ validator: resultV, outcome: true },
{ validator, outcome: true },
]);
}); });
}); });
@ -1049,7 +1052,7 @@ export function runValidateMixinSuite(customConfig) {
await fixture(html` await fixture(html`
<${tag} <${tag}
.modelValue=${'123'} .modelValue=${'123'}
.validators=${[new MinLength(3, { message: 'foo' })]}> .validators=${[new MinLength(3, { getMessage: async () => 'foo' })]}>
<input slot="input"> <input slot="input">
</${tag}>`) </${tag}>`)
); );
@ -1352,14 +1355,14 @@ export function runValidateMixinSuite(customConfig) {
}; };
} }
// @ts-ignore /**
* @param {string} type
*/
_showFeedbackConditionFor(type) { _showFeedbackConditionFor(type) {
switch (type) { switch (type) {
case 'error': case 'error':
// @ts-ignore
return ['A', 'B'].includes(this.modelValue); return ['A', 'B'].includes(this.modelValue);
default: default:
// @ts-ignore
return ['B', 'C'].includes(this.modelValue); return ['B', 'C'].includes(this.modelValue);
} }
} }
@ -1384,12 +1387,10 @@ export function runValidateMixinSuite(customConfig) {
['D', []], ['D', []],
]) { ]) {
el.modelValue = modelValue; el.modelValue = modelValue;
// eslint-disable-next-line no-await-in-loop
await el.updateComplete; await el.updateComplete;
// eslint-disable-next-line no-await-in-loop
await el.feedbackComplete; await el.feedbackComplete;
// @ts-ignore // @ts-expect-error [allow-protected]
const resultOrder = el._feedbackNode.feedbackData.map(v => v.type); const resultOrder = el._feedbackNode.feedbackData.map(v => v.type);
expect(resultOrder).to.deep.equal(expected); expect(resultOrder).to.deep.equal(expected);
} }

View file

@ -7,6 +7,10 @@ import sinon from 'sinon';
import { DefaultSuccess, MinLength, Required, ValidateMixin, Validator } from '../index.js'; import { DefaultSuccess, MinLength, Required, ValidateMixin, Validator } from '../index.js';
import { AlwaysInvalid } from '../test-helpers/index.js'; import { AlwaysInvalid } from '../test-helpers/index.js';
/**
* @typedef {import('../src/validate/types').FeedbackMessageData} FeedbackMessageData
*/
export function runValidateMixinFeedbackPart() { export function runValidateMixinFeedbackPart() {
describe('Validity Feedback', () => { describe('Validity Feedback', () => {
beforeEach(() => { beforeEach(() => {
@ -303,7 +307,7 @@ export function runValidateMixinFeedbackPart() {
await fixture(html` await fixture(html`
<${tag} <${tag}
.submitted=${true} .submitted=${true}
.validators=${[new MinLength(3, { getMessage: () => 'custom via config' })]} .validators=${[new MinLength(3, { getMessage: async () => 'custom via config' })]}
>${lightDom}</${tag}> >${lightDom}</${tag}>
`) `)
); );
@ -397,7 +401,7 @@ export function runValidateMixinFeedbackPart() {
}); });
}); });
describe('Meta data', () => { describe('FeedbackMessageData', () => {
it('".getMessage()" gets a reference to formControl, params, modelValue and type', async () => { it('".getMessage()" gets a reference to formControl, params, modelValue and type', async () => {
class ValidateElementCustomTypes extends ValidateMixin(LitElement) { class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
static get validationTypes() { static get validationTypes() {
@ -431,6 +435,7 @@ export function runValidateMixinFeedbackPart() {
fieldName: '', fieldName: '',
type: 'x', type: 'x',
name: 'MinLength', name: 'MinLength',
outcome: true,
}); });
const instanceMessageSpy = sinon.spy(); const instanceMessageSpy = sinon.spy();
@ -457,6 +462,7 @@ export function runValidateMixinFeedbackPart() {
fieldName: '', fieldName: '',
type: 'error', type: 'error',
name: 'MinLength', name: 'MinLength',
outcome: true,
}); });
}); });
@ -485,41 +491,85 @@ export function runValidateMixinFeedbackPart() {
fieldName: 'myField', fieldName: 'myField',
type: 'error', type: 'error',
name: 'MinLength', name: 'MinLength',
outcome: true,
}); });
}); });
});
it('".getMessage()" gets .fieldName defined on Validator config', async () => { it('".getMessage()" gets .fieldName defined on Validator config', async () => {
const constructorValidator = new MinLength(4, { const constructorValidator = new MinLength(4, {
fieldName: new Promise(resolve => resolve('myFieldViaCfg')), fieldName: new Promise(resolve => resolve('myFieldViaCfg')),
});
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const spy = sinon.spy(ctorValidator, 'getMessage');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
.fieldName=${new Promise(resolve => resolve('myField'))}
>${lightDom}</${tag}>
`)
);
await el.updateComplete;
await el.feedbackComplete;
// ignore fieldName Promise as it will always be unique
const compare = spy.args[0][0];
delete compare?.config?.fieldName;
expect(compare).to.eql({
config: {},
params: 4,
modelValue: 'cat',
formControl: el,
fieldName: 'myFieldViaCfg',
type: 'error',
name: 'MinLength',
outcome: true,
});
}); });
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const spy = sinon.spy(ctorValidator, 'getMessage');
const el = /** @type {ValidateElement} */ ( it('".getMessage()" gets .outcome, which can be "true" or an enum', async () => {
await fixture(html` class EnumOutComeValidator extends Validator {
<${tag} static validatorName = 'EnumOutCome';
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
.fieldName=${new Promise(resolve => resolve('myField'))}
>${lightDom}</${tag}>
`)
);
await el.updateComplete;
await el.feedbackComplete;
// ignore fieldName Promise as it will always be unique execute() {
const compare = spy.args[0][0]; return 'a-string-instead-of-bool';
delete compare?.config?.fieldName; }
expect(compare).to.eql({
config: {}, /**
params: 4, * @param {FeedbackMessageData} meta
modelValue: 'cat', * @returns
formControl: el, */
fieldName: 'myFieldViaCfg', static async getMessage({ outcome }) {
type: 'error', const results = {
name: 'MinLength', 'a-string-instead-of-bool': 'Msg based on enum output',
};
return results[/** @type {string} */ (outcome)];
}
}
const enumOutComeValidator = new EnumOutComeValidator();
const spy = sinon.spy(
/** @type {typeof EnumOutComeValidator} */ (enumOutComeValidator.constructor),
'getMessage',
);
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[enumOutComeValidator]}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`)
);
await el.updateComplete;
await el.feedbackComplete;
const getMessageArs = spy.args[0][0];
expect(getMessageArs.outcome).to.equal('a-string-instead-of-bool');
}); });
}); });