feat: adds display options configurability to the success validation

This commit is contained in:
Robin Van Roy 2025-08-07 17:20:34 +02:00 committed by GitHub
parent 9266a786f2
commit 713429ac80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 173 additions and 36 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': minor
---
adds configuration options to the success message validation

View file

@ -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

View file

@ -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');
}

View file

@ -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 () => {

View file

@ -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}</${elTag}>
`)
);
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}</${elTag}>
`)
);
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}</${elTag}>
`)
);
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() {

View file

@ -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`<lion-validation-feedback></lion-validation-feedback>`)
);
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`<lion-validation-feedback></lion-validation-feedback>`)

View file

@ -31,6 +31,7 @@ export type ValidatorConfig = {
type?: ValidationType;
node?: FormControlHost;
fieldName?: string | Promise<string>;
visibilityDuration?: number;
};
/**