diff --git a/.changeset/serious-toys-hug.md b/.changeset/serious-toys-hug.md new file mode 100644 index 000000000..72577322e --- /dev/null +++ b/.changeset/serious-toys-hug.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': minor +--- + +adds configuration options to the success message validation diff --git a/docs/fundamentals/systems/form/validate.md b/docs/fundamentals/systems/form/validate.md index fdcc0a313..1b617d999 100644 --- a/docs/fundamentals/systems/form/validate.md +++ b/docs/fundamentals/systems/form/validate.md @@ -684,10 +684,26 @@ export const validationTypes = () => { }; ``` +### Success + Success validators work a bit differently. Their success state is defined by the lack of a previously existing erroneous state (which can be an error or warning state). So, an error validator going from invalid (true) state to invalid(false) state, will trigger the success validator. +By default the success message will disappear, both the duration and the removal can be configured by passing `displayOptions` to the validator configuration: + +The `duration` property overwrites the default 3000ms timer. + +```js +new DefaultSuccess({}, { visibilityDuration: 4000 }); +``` + +`Infinity` can be added to the `duration` property to allow the success message to stay on screen, like the behaviour of the other validation types' messages. + +```js +new DefaultSuccess({}, visibilityDuration: Infinity }); +``` + ## Asynchronous validation By default, all validations are run synchronously. However, for instance when validation can only take place on server level, asynchronous validation will be needed. @@ -873,7 +889,7 @@ export const backendValidation = () => { ## Multiple field validation -When validation is dependent on muliple fields, two approaches can be considered: +When validation is dependent on multiple fields, two approaches can be considered: - Fieldset validation - Validators with knowledge about context diff --git a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js index f58bdeecd..743f79bbc 100644 --- a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js +++ b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js @@ -48,6 +48,14 @@ export class LionValidationFeedback extends LocalizeMixin(LitElement) { ]; } + constructor() { + super(); + /** + * @type {FeedbackMessage[] | undefined} + */ + this.feedbackData = undefined; + } + /** * @overridable * @param {Object} opts @@ -69,16 +77,6 @@ export class LionValidationFeedback extends LocalizeMixin(LitElement) { if (this.feedbackData && this.feedbackData[0]) { this.setAttribute('type', this.feedbackData[0].type); this.currentType = this.feedbackData[0].type; - window.clearTimeout(this.removeMessage); - // TODO: this logic should be in ValidateMixin, so that [show-feedback-for] is in sync, - // plus duration should be configurable - if (this.currentType === 'success') { - this.removeMessage = window.setTimeout(() => { - this.removeAttribute('type'); - /** @type {FeedbackMessage[]} */ - this.feedbackData = []; - }, 3000); - } } else if (this.currentType !== 'success') { this.removeAttribute('type'); } diff --git a/packages/ui/components/form-core/src/validate/ValidateMixin.js b/packages/ui/components/form-core/src/validate/ValidateMixin.js index ae5ed857f..37095e497 100644 --- a/packages/ui/components/form-core/src/validate/ValidateMixin.js +++ b/packages/ui/components/form-core/src/validate/ValidateMixin.js @@ -590,7 +590,7 @@ export const ValidateMixinImplementation = superclass => return []; } - // If empty, do not show the ResulValidation message (e.g. Correct!) + // If empty, do not show the ResultValidation message (e.g. Correct!) if (this._isEmpty(this.modelValue)) { this.__prevShownValidationResult = []; return []; @@ -619,7 +619,7 @@ export const ValidateMixinImplementation = superclass => * - on sync validation * - on async validation (can depend on server response) * - * This method inishes a pass by adding the properties to the instance: + * This method finishes a pass by adding the properties to the instance: * - validationStates * - hasFeedbackFor * @@ -763,6 +763,7 @@ export const ValidateMixinImplementation = superclass => * @property {string} type will be 'error' for messages from default Validators. Could be * 'warning', 'info' etc. for Validators with custom types. Needed as a directive for * feedbackNode how to render a message of a certain type + * @property {number} visibilityDuration duration in ms for how long the message should be shown * @property {Validator} [validator] when the message is directly coupled to a Validator * (in most cases), this property is filled. When a message is not coupled to a Validator * (in case of success feedback which is based on a diff or current and previous validation @@ -788,7 +789,12 @@ export const ValidateMixinImplementation = superclass => fieldName, outcome, }); - return { message, type: validator.type, validator }; + return { + message, + type: validator.type, + validator, + visibilityDuration: validator.config?.visibilityDuration || 3000, + }; }), ); } @@ -809,6 +815,8 @@ export const ValidateMixinImplementation = superclass => * @protected */ _updateFeedbackComponent() { + window.clearTimeout(this.removeMessage); + const { _feedbackNode } = this; if (!_feedbackNode) { return; @@ -840,6 +848,18 @@ export const ValidateMixinImplementation = superclass => const messageMap = await this.__getFeedbackMessages(this.__prioritizedResult); _feedbackNode.feedbackData = messageMap || []; + + if ( + messageMap?.[0] && + messageMap[0].type === 'success' && + messageMap[0].visibilityDuration !== Infinity + ) { + this.removeMessage = window.setTimeout(() => { + _feedbackNode.removeAttribute('type'); + /** @type {FeedbackMessage[]} */ + _feedbackNode.feedbackData = []; + }, messageMap[0].visibilityDuration); + } }); } else { this.__feedbackQueue.add(async () => { diff --git a/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js b/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js index 48c51537b..2884bc1ba 100644 --- a/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js +++ b/packages/ui/components/form-core/test-suites/ValidateMixinFeedbackPart.suite.js @@ -349,6 +349,125 @@ export function runValidateMixinFeedbackPart() { expect(_feedbackNode.feedbackData?.[0].message).to.equal('Nachricht für MinLength'); }); + it('shows success message and clears after 3s', async () => { + class ValidateElementCustomTypes extends ValidateMixin(LitElement) { + static get validationTypes() { + return ['error', 'success']; + } + } + const clock = sinon.useFakeTimers(); + const elTagString = defineCE(ValidateElementCustomTypes); + const elTag = unsafeStatic(elTagString); + const el = /** @type {ValidateElementCustomTypes} */ ( + await fixture(html` + <${elTag} + .submitted=${true} + .validators=${[ + new MinLength(3), + new DefaultSuccess(null, { getMessage: () => 'This is a success message' }), + ]} + >${lightDom} + `) + ); + const { _feedbackNode } = getFormControlMembers(el); + + el.modelValue = 'a'; + await el.updateComplete; + await el.feedbackComplete; + expect(_feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength'); + + el.modelValue = 'abcd'; + await el.updateComplete; + await el.feedbackComplete; + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(2900); + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(200); + expect(_feedbackNode.feedbackData).to.be.empty; + }); + + it('shows success message and clears after configured time', async () => { + class ValidateElementCustomTypes extends ValidateMixin(LitElement) { + static get validationTypes() { + return ['error', 'success']; + } + } + const clock = sinon.useFakeTimers(); + const elTagString = defineCE(ValidateElementCustomTypes); + const elTag = unsafeStatic(elTagString); + const el = /** @type {ValidateElementCustomTypes} */ ( + await fixture(html` + <${elTag} + .submitted=${true} + .validators=${[ + new MinLength(3), + new DefaultSuccess(null, { + getMessage: () => 'This is a success message', + visibilityDuration: 6000, + }), + ]} + >${lightDom} + `) + ); + const { _feedbackNode } = getFormControlMembers(el); + + el.modelValue = 'a'; + await el.updateComplete; + await el.feedbackComplete; + expect(_feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength'); + + el.modelValue = 'abcd'; + await el.updateComplete; + await el.feedbackComplete; + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(5900); + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(200); + expect(_feedbackNode.feedbackData).to.be.empty; + }); + + it('shows success message and stays persistent', async () => { + class ValidateElementCustomTypes extends ValidateMixin(LitElement) { + static get validationTypes() { + return ['error', 'success']; + } + } + const clock = sinon.useFakeTimers(); + const elTagString = defineCE(ValidateElementCustomTypes); + const elTag = unsafeStatic(elTagString); + const el = /** @type {ValidateElementCustomTypes} */ ( + await fixture(html` + <${elTag} + .submitted=${true} + .validators=${[ + new MinLength(3), + new DefaultSuccess(null, { + getMessage: () => 'This is a success message', + visibilityDuration: Infinity, + }), + ]} + >${lightDom} + `) + ); + const { _feedbackNode } = getFormControlMembers(el); + + el.modelValue = 'a'; + await el.updateComplete; + await el.feedbackComplete; + expect(_feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength'); + + el.modelValue = 'abcd'; + await el.updateComplete; + await el.feedbackComplete; + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(2900); + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(200); + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + clock.tick(20000); + expect(_feedbackNode.feedbackData?.[0].message).to.equal('This is a success message'); + }); + it('shows success message after fixing an error', async () => { class ValidateElementCustomTypes extends ValidateMixin(LitElement) { static get validationTypes() { diff --git a/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js b/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js index f03316078..464cba4e3 100644 --- a/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js +++ b/packages/ui/components/form-core/test/validate/lion-validation-feedback.test.js @@ -33,28 +33,6 @@ describe('lion-validation-feedback', () => { expect(el.getAttribute('type')).to.equal('warning'); }); - it('success message clears after 3s', async () => { - const el = /** @type {LionValidationFeedback} */ ( - await fixture(html``) - ); - - const clock = sinon.useFakeTimers(); - - el.feedbackData = [{ message: 'hello', type: 'success', validator: new AlwaysValid() }]; - await el.updateComplete; - expect(el.getAttribute('type')).to.equal('success'); - - clock.tick(2900); - - expect(el.getAttribute('type')).to.equal('success'); - - clock.tick(200); - - expect(el).to.not.have.attribute('type'); - - clock.restore(); - }); - it('does not clear error messages', async () => { const el = /** @type {LionValidationFeedback} */ ( await fixture(html``) diff --git a/packages/ui/components/form-core/types/validate/validate.ts b/packages/ui/components/form-core/types/validate/validate.ts index 663536dcf..f125e9d53 100644 --- a/packages/ui/components/form-core/types/validate/validate.ts +++ b/packages/ui/components/form-core/types/validate/validate.ts @@ -31,6 +31,7 @@ export type ValidatorConfig = { type?: ValidationType; node?: FormControlHost; fieldName?: string | Promise; + visibilityDuration?: number; }; /**