fix(form-core): validate on child change + {feedbackType}StateChanged

This commit is contained in:
Thijs Louisse 2021-04-12 19:28:27 +02:00
parent a100fb43df
commit 53167fd205
16 changed files with 245 additions and 32 deletions

View file

@ -0,0 +1,6 @@
---
'@lion/form-core': patch
---
- validation of form groups when child fires 'model-value-changed'
- fire {feedbackType}StateChanged event when feedback type (like 'error'/'warning') changed

View file

@ -4,7 +4,8 @@
import { html, render } from '@lion/core'; import { html, render } from '@lion/core';
import { renderLitAsNode } from '@lion/helpers'; import { renderLitAsNode } from '@lion/helpers';
import '@lion/input/define'; import '@lion/input/define';
import { Validator } from '@lion/form-core'; import '@lion/input-date/define';
import { Validator, Unparseable, MinDate, Required } from '@lion/form-core';
import './assets/h-output.js'; import './assets/h-output.js';
``` ```
@ -118,7 +119,7 @@ export const feedbackCondition = () => {
></lion-input> ></lion-input>
`); `);
// 4. When checkboxes change, set the showFeedbackConditionFor method to a new method that checks // 4. When checkboxes change, set the feedbackCondition method to a new method that checks
// whether every condition that is checked, is true on the field. Otherwise, don't show the feedback. // whether every condition that is checked, is true on the field. Otherwise, don't show the feedback.
const fetchConditionsAndReevaluate = ({ currentTarget: { modelValue } }) => { const fetchConditionsAndReevaluate = ({ currentTarget: { modelValue } }) => {
fieldElement._showFeedbackConditionFor = type => { fieldElement._showFeedbackConditionFor = type => {
@ -142,3 +143,35 @@ export const feedbackCondition = () => {
`; `;
}; };
``` ```
### Changing the feedback show condition (Application Developers)
In some situations, the default condition for showing feedback messages might not apply.
The conditions as described in 'When is feedback shown to the user' can be overidden via
the `feedbackCondition` method.
In this example, we want to implement the following situation:
- for an `input-date`, we have a MinDate condition
- we want to send feedback as soon as we know the user intentionally typed a full Date
(we know if modelValue is not Unparseable)
```js preview-story
export const feedbackVisibility = () => html`
<lion-input-date
.validators="${[
new Required(),
new MinDate(new Date('2000/10/10'), {
getMessage: () => `You provided a correctly formatted date, but it's below MinData`,
}),
]}"
.feedbackCondition="${(type, meta, originalCondition) => {
if (meta.modelValue && !(meta.modelValue instanceof Unparseable)) {
return true;
}
return originalCondition(type, meta);
}}"
help-text="Error appears as soon as a Parseable date before 10/10/2000 is typed"
label="Custom feedback visibility"
></lion-input-date>
`;
```

View file

@ -45,9 +45,11 @@ import {
Required, Required,
Validator, Validator,
Pattern, Pattern,
Unparseable,
} from '@lion/form-core'; } from '@lion/form-core';
import { localize } from '@lion/localize'; import { localize } from '@lion/localize';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages'; import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import { renderLitAsNode } from '@lion/helpers';
``` ```
## When validation happens ## When validation happens
@ -804,3 +806,92 @@ export const backendValidation = () => {
`; `;
}; };
``` ```
## Multiple field validation
When validation is dependent on muliple fields, two approaches can be considered:
- Fieldset validation
- Validators with knowledge about context
### Fieldset validation
Assume we have a field `startDate` and `endDate` field, with condition `startDate` < `endDate`.
The easiest way to achieve this, is by adding a Validator to a fieldset wrapping those fields.
```js preview-story
const isInterpretable = mv => mv && !(mv instanceof Unparseable);
class Interval extends Validator {
static get validatorName() {
return 'Interval';
}
execute({ startDate, endDate }) {
if (isInterpretable(startDate) && isInterpretable(endDate)) {
return !(startDate < endDate);
}
return false;
}
static async getMessage() {
return `The start date should be before the end date`;
}
}
export const fieldsetValidation = () => html`
<lion-fieldset .validators="${[new Interval()]}">
<lion-input-date name="startDate" label="Start date"></lion-input-date>
<lion-input-date name="endDate" label="End date"></lion-input-date>
</lion-fieldset>
`;
```
### Validators with knowledge about context
Assume we want to create password validation with a confirmation field.
We don't want to show feedback on group level, but right beneath the fields.
```js preview-story
const isInterpretableValue = mv => mv && !(mv instanceof Unparseable);
class PasswordMatch extends Validator {
static get validatorName() {
return 'PasswordsMatch';
}
execute(modelValue, { first, second }) {
if (isInterpretableValue(first.modelValue) && isInterpretableValue(second.modelValue)) {
return first.modelValue !== second.modelValue;
}
return false;
}
}
// TODO: use ref directive once on Lit-element 3
const first = renderLitAsNode(html`
<lion-input
.feedbackCondition="${(type, meta) => meta.focused}"
type="password"
name="initialPassword"
label="New password"
></lion-input>
`);
const second = renderLitAsNode(html`
<lion-input
.feedbackCondition="${(type, meta) => meta.focused}"
type="password"
name="confirmPassword"
label="Confirm password"
></lion-input>
`);
first.validators = [
new PasswordMatch(
{ first, second },
{ getMessage: () => 'Please match with confirmation field' },
),
];
second.validators = [
new PasswordMatch({ first, second }, { getMessage: () => 'Please match with initial field' }),
];
export const contextValidation = () => html` ${first}${second} `;
```

View file

@ -201,7 +201,7 @@ const InteractionStateMixinImplementation = superclass =>
get _feedbackConditionMeta() { get _feedbackConditionMeta() {
return { return {
// @ts-ignore // @ts-ignore to fix, InteractionStateMixin needs to depend on ValidateMixin
...super._feedbackConditionMeta, ...super._feedbackConditionMeta,
submitted: this.submitted, submitted: this.submitted,
touched: this.touched, touched: this.touched,

View file

@ -99,4 +99,11 @@ export class LionField extends FormControlMixin(
}), }),
); );
} }
/**
* @configure InteractionStateMixin, ValidateMixin
*/
get _feedbackConditionMeta() {
return { ...super._feedbackConditionMeta, focused: this.focused };
}
} }

View file

@ -85,6 +85,8 @@ export const ValidateMixinImplementation = superclass =>
* By default, just like the platform, only one message (with highest prio) is visible. * By default, just like the platform, only one message (with highest prio) is visible.
*/ */
_visibleMessagesAmount: { attribute: false }, _visibleMessagesAmount: { attribute: false },
__childModelValueChanged: { attribute: false },
}; };
} }
@ -181,6 +183,12 @@ export const ValidateMixinImplementation = superclass =>
this.__onValidatorUpdated = this.__onValidatorUpdated.bind(this); this.__onValidatorUpdated = this.__onValidatorUpdated.bind(this);
/** @protected */ /** @protected */
this._updateFeedbackComponent = this._updateFeedbackComponent.bind(this); this._updateFeedbackComponent = this._updateFeedbackComponent.bind(this);
/**
* This will be used for FormGroups that listen for `model-value-changed` of children
* @private
*/
this.__childModelValueChanged = false;
} }
connectedCallback() { connectedCallback() {
@ -200,6 +208,11 @@ export const ValidateMixinImplementation = superclass =>
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
this.__validateInitialized = true; this.__validateInitialized = true;
this.validate(); this.validate();
if (this._repropagationRole !== 'child') {
this.addEventListener('model-value-changed', () => {
this.__childModelValueChanged = true;
});
}
} }
/** /**
@ -627,8 +640,8 @@ export const ValidateMixinImplementation = superclass =>
} }
/** /**
* The default showFeedbackConditionFor condition that will be used when the * The default feedbackCondition condition that will be used when the
* showFeedbackConditionFor is not overridden. * feedbackCondition is not overridden.
* Show the validity feedback when returning true, don't show when false * Show the validity feedback when returning true, don't show when false
* @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom * @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom
* Validator type * Validator type
@ -641,17 +654,17 @@ export const ValidateMixinImplementation = superclass =>
} }
/** /**
* Allows super classes to add meta info for showFeedbackConditionFor * Allows super classes to add meta info for feedbackCondition
* @configurable * @configurable
*/ */
get _feedbackConditionMeta() { get _feedbackConditionMeta() {
return {}; return { modelValue: this.modelValue, el: this };
} }
/** /**
* Allows the end user to specify when a feedback message should be shown * Allows the end user to specify when a feedback message should be shown
* @example * @example
* showFeedbackConditionFor(type, meta, defaultCondition) { * feedbackCondition(type, meta, defaultCondition) {
* if (type === 'info') { * if (type === 'info') {
* return return; * return return;
* } else if (type === 'prefilledOnly') { * } else if (type === 'prefilledOnly') {
@ -668,7 +681,7 @@ export const ValidateMixinImplementation = superclass =>
* for other types * for other types
* @returns {boolean} * @returns {boolean}
*/ */
showFeedbackConditionFor( feedbackCondition(
type, type,
meta = this._feedbackConditionMeta, meta = this._feedbackConditionMeta,
currentCondition = this._showFeedbackConditionFor.bind(this), currentCondition = this._showFeedbackConditionFor.bind(this),
@ -702,9 +715,30 @@ export const ValidateMixinImplementation = superclass =>
// 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[]} */ (ctor.validationTypes this.showsFeedbackFor = /** @type {string[]} */ (ctor.validationTypes
.map(type => (this._hasFeedbackVisibleFor(type) ? type : undefined)) .map(type => (this._hasFeedbackVisibleFor(type) ? type : undefined))
.filter(_ => !!_)); .filter(Boolean));
this._updateFeedbackComponent(); this._updateFeedbackComponent();
} }
if (changedProperties.has('__childModelValueChanged') && this.__childModelValueChanged) {
this.validate({ clearCurrentResult: true });
this.__childModelValueChanged = false;
}
if (changedProperties.has('validationStates')) {
const prevStates = /** @type {{[key: string]: object;}} */ (changedProperties.get(
'validationStates',
));
if (prevStates) {
Object.entries(this.validationStates).forEach(([type, feedbackObj]) => {
if (
prevStates[type] &&
JSON.stringify(feedbackObj) !== JSON.stringify(prevStates[type])
) {
this.dispatchEvent(new CustomEvent(`${type}StateChanged`, { detail: feedbackObj }));
}
});
}
}
} }
/** /**
@ -717,7 +751,7 @@ export const ValidateMixinImplementation = superclass =>
// 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[]} */ (ctor.validationTypes const newShouldShowFeedbackFor = /** @type {string[]} */ (ctor.validationTypes
.map(type => .map(type =>
this.showFeedbackConditionFor( this.feedbackCondition(
type, type,
this._feedbackConditionMeta, this._feedbackConditionMeta,
this._showFeedbackConditionFor.bind(this), this._showFeedbackConditionFor.bind(this),
@ -725,7 +759,7 @@ export const ValidateMixinImplementation = superclass =>
? type ? type
: undefined, : undefined,
) )
.filter(_ => !!_)); .filter(Boolean));
if (JSON.stringify(this.shouldShowFeedbackFor) !== JSON.stringify(newShouldShowFeedbackFor)) { if (JSON.stringify(this.shouldShowFeedbackFor) !== JSON.stringify(newShouldShowFeedbackFor)) {
this.shouldShowFeedbackFor = newShouldShowFeedbackFor; this.shouldShowFeedbackFor = newShouldShowFeedbackFor;
@ -748,7 +782,7 @@ export const ValidateMixinImplementation = superclass =>
// Sort all validators based on the type provided. // Sort all validators based on the type provided.
const res = validationResult const res = validationResult
.filter(v => .filter(v =>
this.showFeedbackConditionFor( this.feedbackCondition(
v.type, v.type,
this._feedbackConditionMeta, this._feedbackConditionMeta,
this._showFeedbackConditionFor.bind(this), this._showFeedbackConditionFor.bind(this),

View file

@ -17,6 +17,11 @@ import {
AsyncAlwaysInvalid, AsyncAlwaysInvalid,
AsyncAlwaysValid, AsyncAlwaysValid,
} from '../test-helpers/index.js'; } from '../test-helpers/index.js';
import '../define.js';
/**
* @typedef {import('../').LionField} LionField
*/
/** /**
* @param {{tagString?: string | null, lightDom?: string}} [customConfig] * @param {{tagString?: string | null, lightDom?: string}} [customConfig]
@ -133,6 +138,20 @@ export function runValidateMixinSuite(customConfig) {
expect(validateSpy.callCount).to.equal(1); expect(validateSpy.callCount).to.equal(1);
}); });
it('revalidates when child ".modelValue" changes', async () => {
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
._repropagationRole="${'fieldset'}"
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
><lion-field id="child"><input slot="input"></lion-field></${tag}>
`));
const validateSpy = sinon.spy(el, 'validate');
/** @type {LionField} */ (el.querySelector('#child')).modelValue = 'test';
await el.updateComplete;
expect(validateSpy.callCount).to.equal(1);
});
it('revalidates when ".validators" changes', async () => { it('revalidates when ".validators" changes', async () => {
const el = /** @type {ValidateElement} */ (await fixture(html` const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} <${tag}
@ -867,7 +886,7 @@ export function runValidateMixinSuite(customConfig) {
const el = /** @type {ValidateElement} */ (await fixture(html` const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} <${tag}
.validators="${[new Required({}, { type: 'error' })]}" .validators="${[new Required({}, { type: 'error' })]}"
.showFeedbackConditionFor="${( .feedbackCondition="${(
/** @type {string} */ type, /** @type {string} */ type,
/** @type {object} */ meta, /** @type {object} */ meta,
/** @type {(type: string) => any} */ defaultCondition, /** @type {(type: string) => any} */ defaultCondition,
@ -927,6 +946,30 @@ export function runValidateMixinSuite(customConfig) {
await el.updateComplete; await el.updateComplete;
expect(spy).to.have.callCount(2); expect(spy).to.have.callCount(2);
}); });
it('fires "{type}StateChanged" event async when type validity changed', async () => {
const spy = sinon.spy();
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(7)]}
@errorStateChanged=${spy};
>${lightDom}</${tag}>
`));
expect(spy).to.have.callCount(0);
el.modelValue = 'a';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abc';
await el.updateComplete;
expect(spy).to.have.callCount(1);
el.modelValue = 'abcdefg';
await el.updateComplete;
expect(spy).to.have.callCount(2);
});
}); });
}); });

View file

@ -83,18 +83,18 @@ export function runFormGroupMixinSuite(cfg = {}) {
const el = /** @type {FormGroup} */ (await fixture(html` const el = /** @type {FormGroup} */ (await fixture(html`
<${tag} label="foo" .fieldName="${'bar'}">${inputSlots}</${tag}> <${tag} label="foo" .fieldName="${'bar'}">${inputSlots}</${tag}>
`)); `));
// @ts-ignore [allow-proteced] in test // @ts-ignore [allow-protected] in test
expect(el.__fieldName).to.equal(el.fieldName); expect(el.__fieldName).to.equal(el.fieldName);
}); });
// TODO: Tests below belong to FormRegistrarMixin. Preferably run suite integration test // TODO: Tests below belong to FormRegistrarMixin. Preferably run suite integration test
it(`${tagString} has an up to date list of every form element in .formElements`, async () => { it(`${tagString} has an up to date list of every form element in .formElements`, async () => {
const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`)); const el = /** @type {FormGroup} */ (await fixture(html`<${tag}>${inputSlots}</${tag}>`));
// @ts-ignore [allow-proteced] in test // @ts-ignore [allow-protected] in test
expect(el.formElements._keys().length).to.equal(3); expect(el.formElements._keys().length).to.equal(3);
expect(el.formElements['hobbies[]'].length).to.equal(2); expect(el.formElements['hobbies[]'].length).to.equal(2);
el.removeChild(el.formElements['hobbies[]'][0]); el.removeChild(el.formElements['hobbies[]'][0]);
// @ts-ignore [allow-proteced] in test // @ts-ignore [allow-protected] in test
expect(el.formElements._keys().length).to.equal(3); expect(el.formElements._keys().length).to.equal(3);
expect(el.formElements['hobbies[]'].length).to.equal(1); expect(el.formElements['hobbies[]'].length).to.equal(1);
}); });

View file

@ -85,7 +85,7 @@ describe('<lion-field>', () => {
const el = /** @type {LionField} */ (await fixture( const el = /** @type {LionField} */ (await fixture(
html`<${tag} label="foo" .fieldName="${'bar'}">${inputSlot}</${tag}>`, html`<${tag} label="foo" .fieldName="${'bar'}">${inputSlot}</${tag}>`,
)); ));
// @ts-ignore [allow-proteced] in test // @ts-ignore [allow-protected] in test
expect(el.__fieldName).to.equal(el.fieldName); expect(el.__fieldName).to.equal(el.fieldName);
}); });

View file

@ -1,16 +1,9 @@
import { Constructor } from '@open-wc/dedupe-mixin'; import { Constructor } from '@open-wc/dedupe-mixin';
// import { LionField } from '@lion/form-core/src/LionField';
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { FocusHost } from '@lion/form-core/types/FocusMixinTypes'; import { FocusHost } from '@lion/form-core/types/FocusMixinTypes';
import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes'; import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes';
// export declare class NativeTextField extends LionField {
// protected get _inputNode(): HTMLTextAreaElement | HTMLInputElement;
// }
export declare class NativeTextFieldHost { export declare class NativeTextFieldHost {
// protected get _inputNode(): HTMLTextAreaElement | HTMLInputElement;
get selectionStart(): number; get selectionStart(): number;
set selectionStart(value: number); set selectionStart(value: number);
get selectionEnd(): number; get selectionEnd(): number;
@ -26,7 +19,5 @@ export declare function NativeTextFieldImplementation<T extends Constructor<LitE
Pick<typeof FocusHost, keyof typeof FocusHost> & Pick<typeof FocusHost, keyof typeof FocusHost> &
Constructor<FormControlHost> & Constructor<FormControlHost> &
Pick<typeof FormControlHost, keyof typeof FormControlHost>; Pick<typeof FormControlHost, keyof typeof FormControlHost>;
// &
// Pick<typeof NativeTextField, keyof typeof NativeTextField>;
export type NativeTextFieldMixin = typeof NativeTextFieldImplementation; export type NativeTextFieldMixin = typeof NativeTextFieldImplementation;

View file

@ -24,8 +24,8 @@ export declare class FormGroupHost {
protected _initialModelValue: { [x: string]: any }; protected _initialModelValue: { [x: string]: any };
protected get _inputNode(): HTMLElement; protected get _inputNode(): HTMLElement;
protected static _addDescriptionElementIdsToField(): void; protected static _addDescriptionElementIdsToField(): void;
protected _setValueForAllFormElements(property: string, value: any): void; protected _setValueForAllFormElements(property: string, value: any): void;
private __descriptionElementsInParentChain: Set<HTMLElement>; private __descriptionElementsInParentChain: Set<HTMLElement>;
} }

View file

@ -1,6 +1,5 @@
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { Constructor } from '@open-wc/dedupe-mixin'; import { Constructor } from '@open-wc/dedupe-mixin';
import { PropertyValues } from '@lion/core';
export declare interface SyncUpdatableNamespace { export declare interface SyncUpdatableNamespace {
connected?: boolean; connected?: boolean;

View file

@ -36,6 +36,7 @@ export declare class ValidateHost {
protected _visibleMessagesAmount: number; protected _visibleMessagesAmount: number;
protected _allValidators: Validator[]; protected _allValidators: Validator[];
protected get _feedbackConditionMeta(): object;
protected get _feedbackNode(): LionValidationFeedback; protected get _feedbackNode(): LionValidationFeedback;
protected _updateFeedbackComponent(): void; protected _updateFeedbackComponent(): void;
@ -50,6 +51,7 @@ export declare class ValidateHost {
private __validationResult: Validator[]; private __validationResult: Validator[];
private __prevValidationResult: Validator[]; private __prevValidationResult: Validator[];
private __prevShownValidationResult: Validator[]; private __prevShownValidationResult: Validator[];
private __childModelValueChanged: boolean;
private __storePrevResult(): void; private __storePrevResult(): void;
private __executeValidators(): void; private __executeValidators(): void;

View file

@ -10,4 +10,11 @@ import { ListboxMixin } from './ListboxMixin.js';
*/ */
export class LionListbox extends ListboxMixin( export class LionListbox extends ListboxMixin(
FocusMixin(InteractionStateMixin(ValidateMixin(LitElement))), FocusMixin(InteractionStateMixin(ValidateMixin(LitElement))),
) {} ) {
/**
* @configure InteractionStateMixin, ValidateMixin
*/
get _feedbackConditionMeta() {
return { ...super._feedbackConditionMeta, focused: this.focused };
}
}

View file

@ -277,7 +277,7 @@ export function runListboxMixinSuite(customConfig = {}) {
const el = await fixture(html` const el = await fixture(html`
<${tag} label="foo" .fieldName="${'bar'}"></${tag}> <${tag} label="foo" .fieldName="${'bar'}"></${tag}>
`); `);
// @ts-ignore [allow-proteced] in test // @ts-ignore [allow-protected] in test
expect(el.__fieldName).to.equal(el.fieldName); expect(el.__fieldName).to.equal(el.fieldName);
}); });

View file

@ -182,7 +182,7 @@ describe('lion-switch', () => {
const el = await fixture( const el = await fixture(
html`<custom-switch html`<custom-switch
.validators="${[new IsTrue({}, { type: 'info' })]}" .validators="${[new IsTrue({}, { type: 'info' })]}"
.showFeedbackConditionFor="${( .feedbackCondition="${(
/** @type {string} */ type, /** @type {string} */ type,
/** @type {object} */ meta, /** @type {object} */ meta,
/** @type {(type: string, meta: object) => any} */ defaultCondition, /** @type {(type: string, meta: object) => any} */ defaultCondition,