Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(form): support validation change events #2902

Merged
merged 11 commits into from
Jan 17, 2025
13 changes: 6 additions & 7 deletions packages/ts/lit-form/src/BinderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ export class BinderNode<M extends AbstractModel = AbstractModel> extends EventTa

set value(value: Value<M> | undefined) {
this.initializeValue(true);
this.#setValueState(value, undefined);
const oldValue = this.value;
if (value !== oldValue) {
this.#setValueState(value, undefined);
this[_updateValidation]().catch(() => {});
}
}

/**
Expand All @@ -281,7 +285,6 @@ export class BinderNode<M extends AbstractModel = AbstractModel> extends EventTa
set visited(v: boolean) {
if (this.#visited !== v) {
this.#visited = v;
this[_updateValidation]().catch(() => {});
this.dispatchEvent(CHANGED);
}
}
Expand Down Expand Up @@ -406,12 +409,8 @@ export class BinderNode<M extends AbstractModel = AbstractModel> extends EventTa
}

protected async [_updateValidation](): Promise<void> {
if (this.#visited) {
if (this.invalid) {
await this.validate();
} else if (this.dirty || this.invalid) {
await Promise.all(
[...this.#getChildBinderNodes()].map(async (childBinderNode) => childBinderNode[_updateValidation]()),
);
}
}

Expand Down
10 changes: 1 addition & 9 deletions packages/ts/lit-form/src/BinderRoot.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { EndpointValidationError, type ValidationErrorData } from '@vaadin/hilla-frontend/EndpointErrors.js';
import {
_clearValidation,
_setErrorsWithDescendants,
_update,
_updateValidation,
BinderNode,
CHANGED,
} from './BinderNode.js';
import { _clearValidation, _setErrorsWithDescendants, _update, BinderNode, CHANGED } from './BinderNode.js';
import { type FieldElement, type FieldStrategy, getDefaultFieldStrategy } from './Field.js';
import {
_parent,
Expand Down Expand Up @@ -118,7 +111,6 @@ export class BinderRoot<M extends AbstractModel = AbstractModel> extends BinderN
const oldValue = this.#value;
this.#value = newValue;
this[_update](oldValue);
this[_updateValidation]().catch(() => {});
}

/**
Expand Down
81 changes: 72 additions & 9 deletions packages/ts/lit-form/src/Field.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable accessor-pairs,sort-keys */
/* eslint-disable accessor-pairs,no-void,sort-keys */
import { type ElementPart, noChange, nothing, type PropertyPart } from 'lit';
import { directive, Directive, type DirectiveParameters, type PartInfo, PartType } from 'lit/directive.js';
import { getBinderNode } from './BinderNode.js';
Expand Down Expand Up @@ -58,8 +58,12 @@ interface FieldState<T> extends Field<T>, FieldElementHolder<T> {
strategy: FieldStrategy<T>;
}

type EventHandler = (event: Event) => void;

export type FieldStrategy<T = any> = Field<T> &
FieldConstraintValidation & {
onChange?: EventHandler;
onInput?: EventHandler;
removeEventListeners(): void;
};

Expand All @@ -80,6 +84,8 @@ export abstract class AbstractFieldStrategy<T = any, E extends FieldElement<T> =
*/
#validityFallback: ValidityState = defaultValidity;

readonly #eventHandlers = new Map<string, EventHandler>();

constructor(element: E, model?: AbstractModel<T>) {
this.#element = element;
this.model = model;
Expand Down Expand Up @@ -115,6 +121,22 @@ export abstract class AbstractFieldStrategy<T = any, E extends FieldElement<T> =
return this.#element.validity ?? this.#validityFallback;
}

get onChange(): EventHandler | undefined {
return this.#eventHandlers.get('change');
}

set onChange(onChange: EventHandler | undefined) {
this.#setEventHandler('change', onChange);
}

get onInput(): EventHandler | undefined {
return this.#getEventHandler('input');
}

set onInput(onInput: EventHandler | undefined) {
this.#setEventHandler('input', onInput);
}

checkValidity(): boolean {
if (!this.#element.checkValidity) {
return true;
Expand All @@ -137,7 +159,29 @@ export abstract class AbstractFieldStrategy<T = any, E extends FieldElement<T> =
}
}

removeEventListeners(): void {}
removeEventListeners(): void {
for (const [type, handler] of this.#eventHandlers) {
this.element.removeEventListener(type, handler);
this.#eventHandlers.delete(type);
}
}

#getEventHandler(type: string): EventHandler | undefined {
return this.#eventHandlers.get(type);
}

#setEventHandler(type: string, handler?: EventHandler) {
if (this.#eventHandlers.has(type)) {
this.element.removeEventListener(type, this.#eventHandlers.get(type)!);
}

if (handler) {
this.element.addEventListener(type, handler);
this.#eventHandlers.set(type, handler);
} else {
this.#eventHandlers.delete(type);
}
}

#detectValidityError(): Readonly<Partial<ValidityState>> {
if (!('inputElement' in this.#element)) {
Expand Down Expand Up @@ -166,10 +210,12 @@ export class VaadinFieldStrategy<T = any, E extends FieldElement<T> = FieldEleme
> {
#invalid = false;
readonly #boundOnValidated = this.#onValidated.bind(this);
readonly #boundOnUnparsableChange = this.#onUnparsableChange.bind(this);

constructor(element: E, model?: AbstractModel<T>) {
super(element, model);
element.addEventListener('validated', this.#boundOnValidated);
element.addEventListener('unparsable-change', this.#boundOnUnparsableChange);
}

set required(value: boolean) {
Expand All @@ -187,6 +233,7 @@ export class VaadinFieldStrategy<T = any, E extends FieldElement<T> = FieldEleme

override removeEventListeners(): void {
this.element.removeEventListener('validated', this.#boundOnValidated);
this.element.removeEventListener('unparsable-change', this.#boundOnUnparsableChange);
}

#onValidated(e: Event): void {
Expand All @@ -196,10 +243,20 @@ export class VaadinFieldStrategy<T = any, E extends FieldElement<T> = FieldEleme

// Override built-in changes of the `invalid` flag in Vaadin components
// to keep the `invalid` property state of the web component in sync.
const invalid = !(e.detail satisfies Partial<ValidityState> as Partial<ValidityState>).valid;
const invalid = !((e.detail ?? {}) as Partial<ValidityState>).valid;
if (this.#invalid !== invalid) {
this.element.invalid = this.#invalid;
}

// Some user interactions in Vaadin components do not dispatch `input`
// event, such as validation upon closing the overlay, pressing Enter key.
// One notable example is <vaadin-date-picker>. Use `validated` event in
// addition to standard input events to handle those.
this.onInput?.call(this.element, e);
}

#onUnparsableChange(e: Event) {
this.onChange?.call(this.element, e);
}

override checkValidity(): boolean {
Expand Down Expand Up @@ -450,7 +507,7 @@ export const field = directive(

this.fieldState = fieldState;

const updateValueFromElement = () => {
const inputHandler = () => {
fieldState.strategy.checkValidity();
// When bad input is detected, skip reading new value in binder state
if (!fieldState.strategy.validity.badInput) {
Expand All @@ -464,21 +521,27 @@ export const field = directive(
}
};

element.addEventListener('input', updateValueFromElement);
fieldState.strategy.onInput = inputHandler;
fieldState.strategy.onChange = () => {
inputHandler();
void binderNode.validate();
};

const changeBlurHandler = () => {
updateValueFromElement();
const blurHandler = () => {
inputHandler();
void binderNode.validate();
binderNode.visited = true;
};

element.addEventListener('blur', changeBlurHandler);
element.addEventListener('change', changeBlurHandler);
element.addEventListener('blur', blurHandler);
}

const { fieldState } = this;

if (fieldState.element !== element || fieldState.model !== model) {
const { onInput } = fieldState.strategy;
fieldState.strategy = binderNode.binder.getFieldStrategy(element, model);
fieldState.strategy.onInput = onInput;
}

const { name } = binderNode;
Expand Down
68 changes: 63 additions & 5 deletions packages/ts/lit-form/test/Field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,11 @@ describe('@vaadin/hilla-lit-form', () => {
expect(orderViewWithTextField.requestUpdateSpy).to.be.calledOnce;
});

it('should update binder value on blur event', async () => {
it('should update binder value on validated event', async () => {
orderViewWithTextField.requestUpdateSpy.resetHistory();
orderViewWithTextField.notesField!.value = 'foo';
orderViewWithTextField.notesField!.dispatchEvent(
new CustomEvent('blur', { bubbles: true, cancelable: false, composed: true }),
new CustomEvent('validated', { bubbles: true, cancelable: false, composed: true, detail: { valid: true } }),
);
await orderViewWithTextField.updateComplete;

Expand Down Expand Up @@ -838,9 +838,6 @@ describe('@vaadin/hilla-lit-form', () => {
binderNode.value = value;
await resetBinderNodeValidation(binderNode);

binderNode.validators = [];
await binderNode.validate();

binderNode.validators = [{ message: 'any-err-msg', validate: () => false }, new Required()];

element = renderElement();
Expand Down Expand Up @@ -1014,6 +1011,67 @@ describe('@vaadin/hilla-lit-form', () => {
});
});

describe('Event listeners cleanup', () => {
function renderElement(elementName: string) {
const tag = unsafeStatic(elementName);
render(html`<${tag} />`, div);
return div.firstElementChild as HTMLElement;
}

it('should remove event standard listeners for <input>', () => {
const inputHandler = () => {};
const changeHandler = () => {};
const element: HTMLElement = renderElement('input');
const addEventListenerSpy = sinon.spy(element, 'addEventListener');
const removeEventListenerSpy = sinon.spy(element, 'removeEventListener');

const strategy = binder.getFieldStrategy(element, binder.model.fieldString);
strategy.onInput = inputHandler;
strategy.onChange = changeHandler;

expect(addEventListenerSpy).to.be.calledWith('input', inputHandler);
expect(addEventListenerSpy).to.be.calledWith('change', changeHandler);

strategy.onInput = undefined;
strategy.onChange = undefined;

expect(removeEventListenerSpy).to.be.calledWith('input', inputHandler);
expect(removeEventListenerSpy).to.be.calledWith('change', changeHandler);
});

it('should remove event standard listeners for <input> on removeEventListeners()', () => {
const inputHandler = () => {};
const changeHandler = () => {};
const element: HTMLElement = renderElement('input');
const removeEventListenerSpy = sinon.spy(element, 'removeEventListener');

const strategy = binder.getFieldStrategy(element, binder.model.fieldString);
strategy.onInput = inputHandler;
strategy.onChange = changeHandler;

strategy.removeEventListeners();

expect(removeEventListenerSpy).to.be.calledWith('input', inputHandler);
expect(removeEventListenerSpy).to.be.calledWith('change', changeHandler);
});

it('should remove custom event listeners for <vaadin-text-field> on removeEventListeners()', () => {
const element: HTMLElement = renderElement('mock-text-field');
const addEventListenerSpy = sinon.spy(element, 'addEventListener');
const removeEventListenerSpy = sinon.spy(element, 'removeEventListener');

const strategy = binder.getFieldStrategy(element, binder.model.fieldString);

expect(addEventListenerSpy).to.be.calledWith('validated');
expect(addEventListenerSpy).to.be.calledWith('unparsable-change');

strategy.removeEventListeners();

expect(removeEventListenerSpy).to.be.calledWith('validated');
expect(removeEventListenerSpy).to.be.calledWith('unparsable-change');
});
});

describe('Dynamic strategy', () => {
function renderElement<T extends HTMLElement>(tag: string, renderModel: AbstractModel): T {
const tagName = unsafeStatic(tag);
Expand Down
39 changes: 37 additions & 2 deletions packages/ts/lit-form/test/Validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ class NumberOutput extends HTMLElement {
}
customElements.define('number-output', NumberOutput);

class MockDatePickerElement extends HTMLElement {
// pretend it’s a Vaadin component to use VaadinFieldStrategy
static readonly version = '0.0.0';
}
customElements.define('mock-date-picker', MockDatePickerElement);

@customElement('order-view')
class OrderView extends LitElement {
static override readonly styles = css`
Expand Down Expand Up @@ -104,7 +110,7 @@ class OrderView extends LitElement {
<input id="notes" ...="${field(notes)}" />
<input id="fullName" ...="${field(fullName)}" />
<input id="nickName" ...="${field(nickName)}" />
<vaadin-date-picker id="dateStart" ...="${field(dateStart)}" />
<mock-date-picker id="dateStart" ...="${field(dateStart)}" />
<button id="add" @click=${() => this.binder.for(products).appendItem()}>+</button>
${repeat(
products,
Expand Down Expand Up @@ -596,14 +602,24 @@ describe('@vaadin/hilla-lit-form', () => {
expect(orderView.notes).to.not.have.attribute('invalid');
});

it(`should validate field on input after first visit`, async () => {
it(`should not validate field on input after first visit`, async () => {
orderView.notes.value = 'foo';
await fireEvent(orderView.notes, 'blur');
expect(orderView.notes).to.not.have.attribute('invalid');

orderView.notes.value = '';
await fireEvent(orderView.notes, 'input');
expect(orderView.notes).to.not.have.attribute('invalid');
});

it(`should revalidate field on input after invalid change`, async () => {
orderView.notes.value = '';
await fireEvent(orderView.notes, 'change');
expect(orderView.notes).to.have.attribute('invalid');

orderView.notes.value = 'foo';
await fireEvent(orderView.notes, 'input');
expect(orderView.notes).to.not.have.attribute('invalid');
});

it(`should validate fields on submit`, async () => {
Expand Down Expand Up @@ -850,6 +866,25 @@ describe('@vaadin/hilla-lit-form', () => {
errors = await binder.validate();
expect(errors).to.have.length(0);
});

it('should track unparsable-change event and fail validation', async () => {
const value = binder.defaultValue;
value.customer.fullName = 'Jane Doe';
value.notes = '42';
value.total = 1;
value.priority = 0;
value.dateStart = '02-11-2099';
binder.value = value;
await orderView.updateComplete;

// Simulate bad user date input with unparsable-change event
orderView.dateStart.value = 'not a date';
await fireEvent(orderView.dateStart, 'unparsable-change');

const errors = await binder.validate();
expect(errors).to.have.length(1);
expect(errors[0]).to.have.property('property', 'dateStart');
});
});

describe('message interpolation', () => {
Expand Down
Loading
Loading