From 98a766173638246b6a17e31812929a9bba1eb52b Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 18 Dec 2021 17:23:07 -0500 Subject: [PATCH] feat(binding): make Binding Service a little smarter - 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) --- .../src/__tests__/binding.service.spec.ts | 31 ++++++++++++++++--- packages/binding/src/binding.service.ts | 21 ++++++++----- .../__tests__/bindingEvent.service.spec.ts | 20 ++++++++++++ .../src/services/bindingEvent.service.ts | 27 +++++++++++----- .../event-pub-sub/src/eventPubSub.service.ts | 3 +- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/binding/src/__tests__/binding.service.spec.ts b/packages/binding/src/__tests__/binding.service.spec.ts index 6965c7628..62fb4aed7 100644 --- a/packages/binding/src/__tests__/binding.service.spec.ts +++ b/packages/binding/src/__tests__/binding.service.spec.ts @@ -1,3 +1,4 @@ +import 'jest-extended'; import { BindingService } from '../binding.service'; describe('Binding Service', () => { @@ -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(); diff --git a/packages/binding/src/binding.service.ts b/packages/binding/src/binding.service.ts index b99636f9b..e7f71ba6e 100644 --- a/packages/binding/src/binding.service.ts +++ b/packages/binding/src/binding.service.ts @@ -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 | null, attribute: string, eventName?: string, callback?: (val: any) => any) { - if (elements && (elements as NodeListOf).forEach) { + bind(elements: T | NodeListOf | null, attribute: string, eventName?: string, callback?: (val: any) => any) { + if (elements && (elements as NodeListOf).forEach) { // multiple DOM elements coming from a querySelectorAll() call - (elements as NodeListOf).forEach(elm => this.bindSingleElement(elm, attribute, eventName, callback)); + (elements as NodeListOf).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; @@ -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(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(); @@ -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; } -} +} \ No newline at end of file diff --git a/packages/common/src/services/__tests__/bindingEvent.service.spec.ts b/packages/common/src/services/__tests__/bindingEvent.service.spec.ts index 680c84670..8ec5b077a 100644 --- a/packages/common/src/services/__tests__/bindingEvent.service.spec.ts +++ b/packages/common/src/services/__tests__/bindingEvent.service.spec.ts @@ -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'); diff --git a/packages/common/src/services/bindingEvent.service.ts b/packages/common/src/services/bindingEvent.service.ts index 35c2d0d48..55d88b080 100644 --- a/packages/common/src/services/bindingEvent.service.ts +++ b/packages/common/src/services/bindingEvent.service.ts @@ -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, 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)?.forEach) { + (elementOrElements as NodeListOf)?.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, 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); + } } } } diff --git a/packages/event-pub-sub/src/eventPubSub.service.ts b/packages/event-pub-sub/src/eventPubSub.service.ts index db05031ea..1fb97c637 100644 --- a/packages/event-pub-sub/src/eventPubSub.service.ts +++ b/packages/event-pub-sub/src/eventPubSub.service.ts @@ -41,8 +41,7 @@ export class EventPubSubService implements PubSubService { if (delay) { return new Promise(resolve => { - const isDispatched = this.dispatchCustomEvent(eventNameByConvention, data, true, true); - setTimeout(() => resolve(isDispatched), delay); + setTimeout(() => resolve(this.dispatchCustomEvent(eventNameByConvention, data, true, true)), delay); }); } else { return this.dispatchCustomEvent(eventNameByConvention, data, true, true);