Skip to content

Commit

Permalink
feat(binding): make Binding Service a little smarter
Browse files Browse the repository at this point in the history
- when detecting binding on an input of type number, it will also parse the value to a number (prior to this PR, it was leaving the value as a string because html input are always string)
- also add possibility to provide multiple elements to the same binding (e,g, it allows to bind 1 or more element(s) to the same listener, a good example is a modal window which often has 2 close buttons, 1 is an "x" and typically also a "Close" button and they should both execute the same action)
  • Loading branch information
ghiscoding committed Dec 18, 2021
1 parent cfba980 commit 98a7661
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 20 deletions.
31 changes: 27 additions & 4 deletions packages/binding/src/__tests__/binding.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'jest-extended';
import { BindingService } from '../binding.service';

describe('Binding Service', () => {
Expand Down Expand Up @@ -32,19 +33,41 @@ describe('Binding Service', () => {
expect(mockCallback).toHaveBeenCalled();
});

it('should return same input value when object property is not found', () => {
it('should add a binding for an input type number and call a value change and expect a mocked object to have the reflected value AND parsed as a number', () => {
const mockCallback = jest.fn();
const mockObj = { name: 'John', age: 20 };
const elm = document.createElement('input');
elm.type = 'number';
elm.className = 'custom-class';
div.appendChild(elm);

service = new BindingService({ variable: mockObj, property: 'invalidProperty' });
service = new BindingService({ variable: mockObj, property: 'age' });
service.bind(elm, 'value', 'change', mockCallback);
elm.value = 'Jane';
const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: 'Jane' } } });
elm.value = '30';
const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: '30' } } });
elm.dispatchEvent(mockEvent);

expect(service.property).toBe('age');
expect(mockObj.age).toBe(30);
expect(mockCallback).toHaveBeenCalled();
});

it('should return same input value when object property is not found', () => {
const mockCallback = jest.fn();
const mockObj = { name: 'John', age: 20 };
const elm1 = document.createElement('input');
const elm2 = document.createElement('span');
elm1.className = 'custom-class';
elm2.className = 'custom-class';
div.appendChild(elm1);
div.appendChild(elm2);

service = new BindingService({ variable: mockObj, property: 'invalidProperty' });
service.bind(div.querySelectorAll('.custom-class'), 'value', 'change', mockCallback);
elm1.value = 'Jane';
const mockEvent = new CustomEvent('change', { bubbles: true, detail: { target: { value: 'Jane' } } });
elm1.dispatchEvent(mockEvent);

expect(service.property).toBe('invalidProperty');
expect(mockObj.name).toBe('John');
expect(mockCallback).toHaveBeenCalled();
Expand Down
21 changes: 14 additions & 7 deletions packages/binding/src/binding.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ export class BindingService {
* 2- when an event is provided, we will replace the DOM element (by an attribute) every time an event is triggered
* 2.1- we could also provide an extra callback method to execute when the event gets triggered
*/
bind(elements: Element | NodeListOf<HTMLElement> | null, attribute: string, eventName?: string, callback?: (val: any) => any) {
if (elements && (elements as NodeListOf<HTMLElement>).forEach) {
bind<T extends Element = Element>(elements: T | NodeListOf<T> | null, attribute: string, eventName?: string, callback?: (val: any) => any) {
if (elements && (elements as NodeListOf<T>).forEach) {
// multiple DOM elements coming from a querySelectorAll() call
(elements as NodeListOf<HTMLElement>).forEach(elm => this.bindSingleElement(elm, attribute, eventName, callback));
(elements as NodeListOf<T>).forEach(elm => this.bindSingleElement(elm, attribute, eventName, callback));
} else if (elements) {
// single DOM element coming from a querySelector() call
this.bindSingleElement(elements as Element, attribute, eventName, callback);
this.bindSingleElement(elements as T, attribute, eventName, callback);
}

return this;
Expand Down Expand Up @@ -132,12 +132,15 @@ export class BindingService {
* 2- when an event is provided, we will replace the DOM element (by an attribute) every time an event is triggered
* 2.1- we could also provide an extra callback method to execute when the event gets triggered
*/
protected bindSingleElement(element: Element | null, attribute: string, eventName?: string, callback?: (val: any) => any) {
protected bindSingleElement<T extends Element = Element>(element: T | null, attribute: string, eventName?: string, callback?: (val: any) => any) {
const binding: ElementBinding | ElementBindingWithListener = { element, attribute };
if (element) {
if (eventName) {
const listener = () => {
const elmValue = element[attribute as keyof Element];
let elmValue: any = element[attribute as keyof T];
if (this.hasData(elmValue) && (element as any)?.type === 'number') {
elmValue = +elmValue; // input is always string but we can parse to number when its type is number
}
this.valueSetter(elmValue);
if (this._binding.variable.hasOwnProperty(this._binding.property) || this._binding.property in this._binding.variable) {
this._binding.variable[this._binding.property] = this.valueGetter();
Expand Down Expand Up @@ -167,7 +170,11 @@ export class BindingService {
});
}

protected hasData(value: any): boolean {
return value !== undefined && value !== null && value !== '';
}

protected sanitizeText(dirtyText: string): string {
return (DOMPurify?.sanitize) ? DOMPurify.sanitize(dirtyText, {}) : dirtyText;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ describe('BindingEvent Service', () => {
expect(addEventSpy).toHaveBeenCalledWith('click', mockCallback, { capture: true, passive: true });
});

it('should be able to bind an event with single listener and options to multiple elements', () => {
const mockElm = { addEventListener: jest.fn() } as unknown as HTMLElement;
const mockCallback = jest.fn();
const elm1 = document.createElement('input');
const elm2 = document.createElement('input');
elm1.className = 'custom-class';
elm2.className = 'custom-class';
div.appendChild(elm1);
div.appendChild(elm2);

const btns = div.querySelectorAll('.custom-class');
const addEventSpy1 = jest.spyOn(btns[0], 'addEventListener');
const addEventSpy2 = jest.spyOn(btns[1], 'addEventListener');
service.bind(btns, 'click', mockCallback, { capture: true, passive: true });

expect(service.boundedEvents.length).toBe(2);
expect(addEventSpy1).toHaveBeenCalledWith('click', mockCallback, { capture: true, passive: true });
expect(addEventSpy2).toHaveBeenCalledWith('click', mockCallback, { capture: true, passive: true });
});

it('should call unbindAll and expect as many removeEventListener be called', () => {
const mockElm = { addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as HTMLElement;
const addEventSpy = jest.spyOn(mockElm, 'addEventListener');
Expand Down
27 changes: 20 additions & 7 deletions packages/common/src/services/bindingEvent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,33 @@ export class BindingEventService {
}

/** Bind an event listener to any element */
bind(element: Element, eventNameOrNames: string | string[], listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) {
bind(elementOrElements: Element | NodeListOf<Element>, eventNameOrNames: string | string[], listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) {
const eventNames = (Array.isArray(eventNameOrNames)) ? eventNameOrNames : [eventNameOrNames];
for (const eventName of eventNames) {
element.addEventListener(eventName, listener, options);
this._boundedEvents.push({ element, eventName, listener });

if ((elementOrElements as NodeListOf<HTMLElement>)?.forEach) {
(elementOrElements as NodeListOf<HTMLElement>)?.forEach(element => {
for (const eventName of eventNames) {
element.addEventListener(eventName, listener, options);
this._boundedEvents.push({ element, eventName, listener });
}
});
} else {
for (const eventName of eventNames) {
(elementOrElements as Element).addEventListener(eventName, listener, options);
this._boundedEvents.push({ element: (elementOrElements as Element), eventName, listener });
}
}
}

/** Unbind all will remove every every event handlers that were bounded earlier */
unbind(element: Element, eventNameOrNames: string | string[], listener: EventListenerOrEventListenerObject) {
unbind(elementOrElements: Element | NodeListOf<Element>, eventNameOrNames: string | string[], listener: EventListenerOrEventListenerObject) {
const elements = (Array.isArray(elementOrElements)) ? elementOrElements : [elementOrElements];
const eventNames = Array.isArray(eventNameOrNames) ? eventNameOrNames : [eventNameOrNames];
for (const eventName of eventNames) {
if (element?.removeEventListener) {
element.removeEventListener(eventName, listener);
for (const element of elements) {
if (element?.removeEventListener) {
element.removeEventListener(eventName, listener);
}
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions packages/event-pub-sub/src/eventPubSub.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ export class EventPubSubService implements PubSubService {

if (delay) {
return new Promise(resolve => {
const isDispatched = this.dispatchCustomEvent<T>(eventNameByConvention, data, true, true);
setTimeout(() => resolve(isDispatched), delay);
setTimeout(() => resolve(this.dispatchCustomEvent<T>(eventNameByConvention, data, true, true)), delay);
});
} else {
return this.dispatchCustomEvent<T>(eventNameByConvention, data, true, true);
Expand Down

0 comments on commit 98a7661

Please sign in to comment.