From 7016a150dc9574daca7e9406f5d03f04769e1902 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 16 Mar 2022 10:03:34 +0100 Subject: [PATCH] feat(form-core): allow enums as outcome of a Validator --- .changeset/proud-geese-suffer.md | 5 + .../form-core/src/validate/ValidateMixin.js | 156 +++++++----- packages/form-core/src/validate/Validator.js | 230 ++++++++++++------ .../test-suites/ValidateMixin.suite.js | 21 +- .../ValidateMixinFeedbackPart.suite.js | 112 ++++++--- 5 files changed, 341 insertions(+), 183 deletions(-) create mode 100644 .changeset/proud-geese-suffer.md diff --git a/.changeset/proud-geese-suffer.md b/.changeset/proud-geese-suffer.md new file mode 100644 index 000000000..4e373eac9 --- /dev/null +++ b/.changeset/proud-geese-suffer.md @@ -0,0 +1,5 @@ +--- +'@lion/form-core': minor +--- + +Validation: allow enums as outcome of a Validator diff --git a/packages/form-core/src/validate/ValidateMixin.js b/packages/form-core/src/validate/ValidateMixin.js index ea1883990..e768e9c8c 100644 --- a/packages/form-core/src/validate/ValidateMixin.js +++ b/packages/form-core/src/validate/ValidateMixin.js @@ -17,6 +17,10 @@ import { FormControlMixin } from '../FormControlMixin.js'; /** * @typedef {import('../../types/validate/ValidateMixinTypes').ValidateMixin} ValidateMixin * @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 = []; - // TODO: [v1] make this fully private (preifix __)? + // TODO: [v1] make this fully private (prefix __)? /** * A temporary storage to transition from hasFeedbackFor to showsFeedbackFor * @type {ValidationType[]} @@ -171,7 +175,7 @@ export const ValidateMixinImplementation = superclass => /** * The outcome of a validation 'round'. Keyed by ValidationType and Validator name * @readOnly - * @type {Object.>} + * @type {ValidationStates} */ this.validationStates = {}; @@ -206,44 +210,45 @@ export const ValidateMixinImplementation = superclass => /** * The amount of feedback messages that will visible in LionValidationFeedback + * @configurable * @protected */ this._visibleMessagesAmount = 1; /** - * @type {Validator[]} + * @type {ValidationResultEntry[]} * @private */ this.__syncValidationResult = []; /** - * @type {Validator[]} + * @type {ValidationResultEntry[]} * @private */ this.__asyncValidationResult = []; /** * Aggregated result from sync Validators, async Validators and ResultValidators - * @type {Validator[]} + * @type {ValidationResultEntry[]} * @private */ this.__validationResult = []; /** - * @type {Validator[]} + * @type {ValidationResultEntry[]} * @private */ this.__prevValidationResult = []; /** - * @type {Validator[]} + * @type {ValidationResultEntry[]} * @private */ this.__prevShownValidationResult = []; /** * The updated children validity affects the validity of the parent. Helper to recompute - * validatity of parent FormGroup + * validity of parent FormGroup * @private */ this.__childModelValueChanged = false; @@ -337,7 +342,7 @@ export const ValidateMixinImplementation = superclass => * Triggered by: * - modelValue change * - 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: * - 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()`) */ async __executeValidators() { + /** + * Allows Application Developer to wait for (async) validation + * @example + * ```js + * await el.validateComplete; + * ``` + * @type {Promise} + */ this.validateComplete = new Promise(resolve => { this.__validateCompleteResolve = resolve; }); @@ -410,7 +423,7 @@ export const ValidateMixinImplementation = superclass => const isEmpty = this.__isEmpty(value); if (isEmpty) { if (requiredValidator) { - this.__syncValidationResult = [requiredValidator]; + this.__syncValidationResult = [{ validator: requiredValidator, outcome: true }]; } this.__finishValidation({ source: 'sync' }); return; @@ -451,9 +464,12 @@ export const ValidateMixinImplementation = superclass => */ __executeSyncValidators(syncValidators, value, { hasAsync }) { if (syncValidators.length) { - this.__syncValidationResult = syncValidators.filter(v => - v.execute(value, v.param, { node: this }), - ); + this.__syncValidationResult = syncValidators + .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 }); } @@ -468,10 +484,15 @@ export const ValidateMixinImplementation = superclass => if (asyncValidators.length) { this.isPending = true; const resultPromises = asyncValidators.map(v => v.execute(value, v.param, { node: this })); - const booleanResults = await Promise.all(resultPromises); - this.__asyncValidationResult = booleanResults - .map((r, i) => asyncValidators[i]) // Create an array of Validators - .filter((v, i) => booleanResults[i]); // Only leave the ones returning true + const asyncExecutionResults = await Promise.all(resultPromises); + + this.__asyncValidationResult = asyncExecutionResults + .map((r, i) => ({ + validator: asyncValidators[i], + outcome: /** @type {boolean|string} */ (asyncExecutionResults[i]), + })) + .filter(v => Boolean(v.outcome)); + this.__finishValidation({ source: 'async' }); this.isPending = false; } @@ -479,7 +500,7 @@ export const ValidateMixinImplementation = superclass => /** * 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 */ __executeResultValidators(regularValidationResult) { @@ -490,13 +511,21 @@ export const ValidateMixinImplementation = superclass => }) ); - return resultValidators.filter(v => - v.executeOnResults({ - regularValidationResult, - prevValidationResult: this.__prevValidationResult, - prevShownValidationResult: this.__prevShownValidationResult, - }), - ); + // Map everything to Validator[] for backwards compatibility + return resultValidators + .map(v => ({ + validator: v, + 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); this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome]; - // this._storeResultsOnInstance(this.__validationResult); - const ctor = - /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ ( - this.constructor - ); + const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); - /** @type {Object.>} */ + /** @type {ValidationStates} */ const validationStates = ctor.validationTypes.reduce( (acc, type) => ({ ...acc, [type]: {} }), {}, ); - this.__validationResult.forEach(v => { - if (!validationStates[v.type]) { - validationStates[v.type] = {}; + this.__validationResult.forEach(({ validator, outcome }) => { + if (!validationStates[validator.type]) { + validationStates[validator.type] = {}; } - const vCtor = /** @type {typeof Validator} */ (v.constructor); - validationStates[v.type][vCtor.validatorName] = true; + const vCtor = /** @type {typeof Validator} */ (validator.constructor); + validationStates[validator.type][vCtor.validatorName] = outcome; }); 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 */ this.dispatchEvent(new Event('validate-performed', { bubbles: true })); if (source === 'async' || !hasAsync) { if (this.__validateCompleteResolve) { - // @ts-ignore [allow-private] - this.__validateCompleteResolve(); + this.__validateCompleteResolve(true); } } } @@ -587,10 +613,7 @@ export const ValidateMixinImplementation = superclass => console.error(errorMessage, this); throw new Error(errorMessage); } - const ctor = - /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ ( - this.constructor - ); + const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); if (ctor.validationTypes.indexOf(v.type) === -1) { const vCtor = /** @type {typeof Validator} */ (v.constructor); // 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.} * @private */ - async __getFeedbackMessages(validators) { + async __getFeedbackMessages(validationResults) { let fieldName = await this.fieldName; return Promise.all( - validators.map(async validator => { + validationResults.map(async ({ validator, outcome }) => { if (validator.config.fieldName) { fieldName = await validator.config.fieldName; } @@ -656,6 +679,7 @@ export const ValidateMixinImplementation = superclass => modelValue: this.modelValue, formControl: this, fieldName, + outcome, }); return { message, type: validator.type, validator }; }), @@ -690,10 +714,19 @@ export const ValidateMixinImplementation = superclass => if (this.showsFeedbackFor.length > 0) { this.__feedbackQueue.add(async () => { /** @type {Validator[]} */ - this.__prioritizedResult = this._prioritizeAndFilterFeedback({ - validationResult: this.__validationResult, + const prioritizedValidators = this._prioritizeAndFilterFeedback({ + 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) { 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 * ```js * feedbackCondition(type, meta, defaultCondition) { * if (type === 'info') { - * return return; + * return true; * } else if (type === 'prefilledOnly') { * 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) { super.updated(changedProperties); @@ -783,10 +818,7 @@ export const ValidateMixinImplementation = superclass => changedProperties.has('shouldShowFeedbackFor') || changedProperties.has('hasFeedbackFor') ) { - const ctor = - /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ ( - this.constructor - ); + const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); // Necessary typecast because types aren't smart enough to understand that we filter out undefined this.showsFeedbackFor = /** @type {string[]} */ ( ctor.validationTypes @@ -822,10 +854,7 @@ export const ValidateMixinImplementation = superclass => * @protected */ _updateShouldShowFeedbackFor() { - const ctor = - /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ ( - this.constructor - ); + const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); // Necessary typecast because types aren't smart enough to understand that we filter out undefined const newShouldShowFeedbackFor = /** @type {string[]} */ ( @@ -848,18 +877,15 @@ export const ValidateMixinImplementation = superclass => } /** - * Orders all active validators in this.__validationResult. Can - * also filter out occurrences (based on interaction states) + * Orders all active validators in this.__validationResult. + * Can also filter out occurrences (based on interaction states) * @overridable * @param {{ validationResult: Validator[] }} opts * @return {Validator[]} ordered list of Validators with feedback messages visible to the end user * @protected */ _prioritizeAndFilterFeedback({ validationResult }) { - const ctor = - /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ ( - this.constructor - ); + const ctor = /** @type {ValidateHostConstructor} */ (this.constructor); const types = ctor.validationTypes; // Sort all validators based on the type provided. const res = validationResult diff --git a/packages/form-core/src/validate/Validator.js b/packages/form-core/src/validate/Validator.js index 03a28da3b..f272a3c0e 100644 --- a/packages/form-core/src/validate/Validator.js +++ b/packages/form-core/src/validate/Validator.js @@ -1,44 +1,65 @@ /** - * @typedef {object} MessageData - * @property {*} [MessageData.modelValue] - * @property {string} [MessageData.fieldName] - * @property {HTMLElement} [MessageData.formControl] - * @property {string} [MessageData.type] - * @property {Object.} [MessageData.config] - * @property {string} [MessageData.name] + * @typedef {import('./types').FeedbackMessageData} FeedbackMessageData + * @typedef {import('./types').ValidatorParam} ValidatorParam + * @typedef {import('./types').ValidatorConfig} ValidatorConfig + * @typedef {import('./types').ValidatorOutcome} ValidatorOutcome + * @typedef {import('./types').ValidatorName} ValidatorName + * @typedef {import('./types').ValidationType} ValidationType + * @typedef {import('../FormControlMixin').FormControlHost} FormControlHost */ -export class Validator { +// TODO: support attribute validators like => +// 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 {?} [param] - * @param {Object.} [config] + * @param {ValidatorParam} [param] + * @param {ValidatorConfig} [config] */ constructor(param, config) { - this.__fakeExtendsEventTarget(); + super(); - /** @type {?} */ + /** @type {ValidatorParam} */ this.__param = param; - - /** @type {Object.} */ + /** @type {ValidatorConfig} */ this.__config = config || {}; - this.type = (config && config.type) || 'error'; // Default type supported by ValidateMixin - } - - static get validatorName() { - return ''; - } - - static get async() { - return false; + /** @type {ValidationType} */ + this.type = config?.type || 'error'; // Default type supported by ValidateMixin } /** - * @desc The function that returns a Boolean - * @param {?} [modelValue] - * @param {?} [param] - * @param {{}} [config] - * @returns {Boolean|Promise} + * The name under which validation results get registered. For convience and predictability, this + * should always be the same as the constructor name (since it will be obfuscated in js builds, + * we need to provide it separately). + * @type {ValidatorName} + */ + 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} */ // eslint-disable-next-line no-unused-vars, class-methods-use-this execute(modelValue, param, config) { @@ -51,22 +72,55 @@ export class Validator { 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``, document.body); + * // Based on some event, we need to change the param + * myValidatorInstance.param = 2; + * ``` + * @property {ValidatorParam} + */ set 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() { 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``, document.body); + * // Based on some event, we need to change the param + * myValidatorInstance.config = {getMessage() => 'y'}; + * ``` + * @property {ValidatorConfig} + */ set 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() { @@ -74,9 +128,29 @@ export class Validator { } /** - * @overridable - * @param {MessageData} [data] - * @returns {Promise} + * This is a protected method that usually should not be overridden. It is called by ValidateMixin + * and it gathers data to be passed to getMessage functions found: + * - `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} [data] + * @returns {Promise} * @protected */ 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 - * @param {MessageData} [data] - * @returns {Promise} + * @param {Partial} [data] + * @returns {Promise} */ // eslint-disable-next-line no-unused-vars 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 /** - * @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 @@ -130,41 +241,6 @@ export class Validator { * - Or, when a webworker was started, its process could be aborted and then restarted. */ 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: diff --git a/packages/form-core/test-suites/ValidateMixin.suite.js b/packages/form-core/test-suites/ValidateMixin.suite.js index 8dc835d2e..8ab767288 100644 --- a/packages/form-core/test-suites/ValidateMixin.suite.js +++ b/packages/form-core/test-suites/ValidateMixin.suite.js @@ -70,7 +70,7 @@ export function runValidateMixinSuite(customConfig) { */ 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 const stub = sinon.stub(console, 'error'); const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}>`)); @@ -93,7 +93,7 @@ export function runValidateMixinSuite(customConfig) { 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 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() {}".`; @@ -720,7 +720,10 @@ export function runValidateMixinSuite(customConfig) { // @ts-ignore [allow-private] in test 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` <${tag} .modelValue=${'123'} - .validators=${[new MinLength(3, { message: 'foo' })]}> + .validators=${[new MinLength(3, { getMessage: async () => 'foo' })]}> `) ); @@ -1352,14 +1355,14 @@ export function runValidateMixinSuite(customConfig) { }; } - // @ts-ignore + /** + * @param {string} type + */ _showFeedbackConditionFor(type) { switch (type) { case 'error': - // @ts-ignore return ['A', 'B'].includes(this.modelValue); default: - // @ts-ignore return ['B', 'C'].includes(this.modelValue); } } @@ -1384,12 +1387,10 @@ export function runValidateMixinSuite(customConfig) { ['D', []], ]) { el.modelValue = modelValue; - // eslint-disable-next-line no-await-in-loop await el.updateComplete; - // eslint-disable-next-line no-await-in-loop await el.feedbackComplete; - // @ts-ignore + // @ts-expect-error [allow-protected] const resultOrder = el._feedbackNode.feedbackData.map(v => v.type); expect(resultOrder).to.deep.equal(expected); } diff --git a/packages/form-core/test-suites/ValidateMixinFeedbackPart.suite.js b/packages/form-core/test-suites/ValidateMixinFeedbackPart.suite.js index 4108a99e2..bd92897be 100644 --- a/packages/form-core/test-suites/ValidateMixinFeedbackPart.suite.js +++ b/packages/form-core/test-suites/ValidateMixinFeedbackPart.suite.js @@ -7,6 +7,10 @@ import sinon from 'sinon'; import { DefaultSuccess, MinLength, Required, ValidateMixin, Validator } from '../index.js'; import { AlwaysInvalid } from '../test-helpers/index.js'; +/** + * @typedef {import('../src/validate/types').FeedbackMessageData} FeedbackMessageData + */ + export function runValidateMixinFeedbackPart() { describe('Validity Feedback', () => { beforeEach(() => { @@ -303,7 +307,7 @@ export function runValidateMixinFeedbackPart() { await fixture(html` <${tag} .submitted=${true} - .validators=${[new MinLength(3, { getMessage: () => 'custom via config' })]} + .validators=${[new MinLength(3, { getMessage: async () => 'custom via config' })]} >${lightDom} `) ); @@ -397,7 +401,7 @@ export function runValidateMixinFeedbackPart() { }); }); - describe('Meta data', () => { + describe('FeedbackMessageData', () => { it('".getMessage()" gets a reference to formControl, params, modelValue and type', async () => { class ValidateElementCustomTypes extends ValidateMixin(LitElement) { static get validationTypes() { @@ -431,6 +435,7 @@ export function runValidateMixinFeedbackPart() { fieldName: '', type: 'x', name: 'MinLength', + outcome: true, }); const instanceMessageSpy = sinon.spy(); @@ -457,6 +462,7 @@ export function runValidateMixinFeedbackPart() { fieldName: '', type: 'error', name: 'MinLength', + outcome: true, }); }); @@ -485,41 +491,85 @@ export function runValidateMixinFeedbackPart() { fieldName: 'myField', type: 'error', name: 'MinLength', + outcome: true, }); }); - }); - it('".getMessage()" gets .fieldName defined on Validator config', async () => { - const constructorValidator = new MinLength(4, { - fieldName: new Promise(resolve => resolve('myFieldViaCfg')), + it('".getMessage()" gets .fieldName defined on Validator config', async () => { + const constructorValidator = new MinLength(4, { + 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} + `) + ); + 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} */ ( - await fixture(html` - <${tag} - .submitted=${true} - .validators=${[constructorValidator]} - .modelValue=${'cat'} - .fieldName=${new Promise(resolve => resolve('myField'))} - >${lightDom} - `) - ); - await el.updateComplete; - await el.feedbackComplete; + it('".getMessage()" gets .outcome, which can be "true" or an enum', async () => { + class EnumOutComeValidator extends Validator { + static validatorName = 'EnumOutCome'; - // 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', + execute() { + return 'a-string-instead-of-bool'; + } + + /** + * @param {FeedbackMessageData} meta + * @returns + */ + static async getMessage({ outcome }) { + const results = { + '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} + `) + ); + await el.updateComplete; + await el.feedbackComplete; + + const getMessageArs = spy.args[0][0]; + expect(getMessageArs.outcome).to.equal('a-string-instead-of-bool'); }); });