Repository: exadel-inc/esl
Pull request #1118 (2022-07-22 11:29:30)

feat(esl-utils): extend SynteticEventTarget with ability to separate events enhancement
In the scope of this PR I extended SynteticEventTarget to separate events by their type.
The end result repeats implementation of native event storage.
Code base tests were extended and refactored.


Patch:
diff --git a/src/modules/esl-traversing-query/core/esl-traversing-query.ts b/src/modules/esl-traversing-query/core/esl-traversing-query.ts
index 8b2f5a1a2..b34818567 100644
--- a/src/modules/esl-traversing-query/core/esl-traversing-query.ts
+++ b/src/modules/esl-traversing-query/core/esl-traversing-query.ts
@@ -95,8 +95,35 @@ export class TraversingQuery {
     }
     return uniq(result);
   }
+  /** Split multiple queries separated by comma (respects query brackets) */
+  // This can be solved by RegEx /(?<!\([^\)]*),(?![^\(]*\))/g)/, when the WebKit browser implements this feature
+  public static splitQueries(str: string): string[] {
+    let last = 0;
+    let stack = 0;
+    const result = [];
+    for (let i = 0; i < str.length; i++) {
+      if (str[i] === '(') stack++;
+      if (str[i] === ')') stack = Math.max(0, stack - 1);
+      if (str[i] === ',' && !stack) {
+        result.push(str.substring(last, i).trim());
+        last = i + 1;
+      }
+    }
+    result.push(str.substring(last).trim());
+    return result;
+  }
+
+  protected static traverse(query: string, findFirst: boolean, base?: Element | null, scope: Element | Document = document): Element[] {
+    const found: Element[] = [];
+    for (const part of TraversingQuery.splitQueries(query)) {
+      const els = this.traverseQuery(part, findFirst, base, scope);
+      if (findFirst && els.length) return [els[0]];
+      found.push(...els);
+    }
+    return found;
+  }
 
-  static traverse(query: string, findFirst: boolean, base?: Element | null, scope: Element | Document = document): Element[] {
+  protected static traverseQuery(query: string, findFirst: boolean, base?: Element | null, scope: Element | Document = document): Element[] {
     const parts = query.split(this.PROCESSORS_REGEX).map((term) => term.trim());
     const rootSel = parts.shift();
     const baseCollection = base ? [base] : [];
diff --git a/src/modules/esl-utils/dom/events/target.ts b/src/modules/esl-utils/dom/events/target.ts
index 19af184ea..abb6998b2 100644
--- a/src/modules/esl-utils/dom/events/target.ts
+++ b/src/modules/esl-utils/dom/events/target.ts
@@ -1,35 +1,38 @@
 /**
  * Synthetic implementation of EventTarget
- * Uses a Set collection of listeners
- * Ignores event type
+ * Replicates behavior of native event
+ * Doesn't give explicit access to callback storage
  */
 export class SyntheticEventTarget implements EventTarget {
-  private readonly _listeners = new Set<EventListenerOrEventListenerObject>();
+  private readonly _listeners: Record<string, EventListenerOrEventListenerObject[]> = {};
 
-  // Parameter "type" will be used in future update
   public hasEventListener(): boolean;
   public hasEventListener(type: string | number): boolean;
   public hasEventListener(type: string, minCount: number): boolean;
   public hasEventListener(type: string | number = 'change', minCount: number = 0): boolean {
-    if (typeof type === 'number') {
-      minCount = type;
-      type = 'change';
-    }
-    return this._listeners.size > minCount;
+    if (typeof type !== 'string') return this.hasEventListener('change', type || 0);
+    return this._listeners[type]?.length > minCount;
   }
 
   public addEventListener(callback: EventListenerOrEventListenerObject): void;
-  public addEventListener(type: 'change', callback: EventListenerOrEventListenerObject): void;
-  public addEventListener(type: any, callback: EventListenerOrEventListenerObject = type): void {
+  public addEventListener(type: string, callback: EventListenerOrEventListenerObject): void;
+  public addEventListener(type: string | EventListenerOrEventListenerObject, callback?: EventListenerOrEventListenerObject): void {
+    if (typeof type !== 'string') return this.addEventListener('change', type);
+
     validateEventListenerType(callback);
-    this._listeners.add(callback);
+    if (this._listeners[type] && this._listeners[type].includes(callback!)) return;
+    if (this._listeners[type]) this._listeners[type].push(callback!);
+    else Object.assign(this._listeners, {[type]: [callback]});
   }
 
   public removeEventListener(callback: EventListenerOrEventListenerObject): void;
-  public removeEventListener(type: 'change', callback: EventListenerOrEventListenerObject): void;
-  public removeEventListener(type: any, callback: EventListenerOrEventListenerObject = type): void {
+  public removeEventListener(type: string, callback: EventListenerOrEventListenerObject): void;
+  public removeEventListener(type: string | EventListenerOrEventListenerObject, callback?: EventListenerOrEventListenerObject): void {
+    if (typeof type !== 'string') return this.removeEventListener('change', type);
+
     validateEventListenerType(callback);
-    this._listeners.delete(callback);
+    if (!this._listeners[type]) return;
+    this._listeners[type] = this._listeners[type].filter((cb) => cb !== callback);
   }
 
   public dispatchEvent(e: Event): boolean {
@@ -37,7 +40,7 @@ export class SyntheticEventTarget implements EventTarget {
     Object.defineProperty(e, 'target', {get: target, enumerable: true});
     Object.defineProperty(e, 'currentTarget', {get: target, enumerable: true});
     Object.defineProperty(e, 'srcElement', {get: target, enumerable: true});
-    this._listeners.forEach((listener) => {
+    this._listeners[e.type]?.forEach((listener) => {
       if (typeof listener === 'function') listener.call(this, e);
       else listener.handleEvent.call(listener, e);
     });


Test patch:
diff --git a/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts b/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
index efd755e06..7681f65f4 100644
--- a/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
+++ b/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
@@ -169,4 +169,31 @@ describe('Traversing Query tests', () => {
         .toEqual(expectedCollection);
     });
   });
+  describe('TraversingQuery should support multiple queries separated by comma', () => {
+    test.each([
+      ['::parent,::next', btn1, [row1, btn2]],
+      ['::next,  ::parent', btn1, [btn2, row1]],
+      ['::parent  ,::next', btn2, [row1, btn3]],
+      ['::find(button, article)::filter(:first-child)', row1, [btn1]],
+    ])('TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('TraversingQuery.splitQueries split string with query syntax in mind', () => {
+    test.each([
+      ['', ['']],
+      ['(,)', ['(,)']],
+      ['((,)', ['((,)']],
+      ['()),(,)', ['())', '(,)']],
+      ['))(),((),)', ['))()', '((),)']],
+      ['a,b', ['a', 'b']],
+      ['a, b', ['a', 'b']],
+      ['a,b ,c ', ['a', 'b', 'c']],
+      ['a,(b),c', ['a', '(b)', 'c']],
+      ['a,(b, b),c', ['a', '(b, b)', 'c']],
+      ['(a,b),c', ['(a,b)', 'c']],
+    ])('%s -> %p', (inp: string, out: string[]) =>
+    {
+      expect(TraversingQuery.splitQueries(inp)).toEqual(out);
+    });
+  });
 });
diff --git a/src/modules/esl-utils/dom/events/test/target.test.ts b/src/modules/esl-utils/dom/events/test/target.test.ts
index 329d2f1ff..918a32ba7 100644
--- a/src/modules/esl-utils/dom/events/test/target.test.ts
+++ b/src/modules/esl-utils/dom/events/test/target.test.ts
@@ -1,66 +1,135 @@
 import {SyntheticEventTarget} from '../target';
 
 describe('dom/events: SyntheticEventTarget', () => {
-  describe('handler function', () => {
-    const et = new SyntheticEventTarget();
+  describe('Handler function', () => {
+    describe('Basic functionality', () => {
+      const et = new SyntheticEventTarget();
 
-    test('basic', () => {
       const event1 = new CustomEvent('change');
       const event2 = new CustomEvent('change');
       const event3 = new CustomEvent('change');
+      const event4 = new CustomEvent('click');
+
       const listener = jest.fn();
       et.addEventListener('change', listener);
 
-      expect(listener).toBeCalledTimes(0);
-      et.dispatchEvent(event1);
-      expect(listener).toBeCalledWith(event1);
-      et.dispatchEvent(event2);
-      expect(listener).toHaveBeenLastCalledWith(event2);
-      et.removeEventListener('change', listener);
-      et.dispatchEvent(event3);
-      expect(listener).toBeCalledTimes(2);
+      test('listener shoudn`t be called', () => expect(listener).toBeCalledTimes(0));
+
+      test('listener should be called once', () => {
+        et.dispatchEvent(event1);
+        expect(listener).toBeCalledWith(event1);
+      });
+
+      test('listener should be called two times', () => {
+        et.dispatchEvent(event2);
+        expect(listener).toHaveBeenLastCalledWith(event2);
+      });
+
+      test('listener shouldn`t be called for event it`s not subscribed to', () => {
+        et.dispatchEvent(event4);
+        expect(listener).toHaveBeenLastCalledWith(event2);
+      });
+
+      test('listener should be stored', () => {
+        expect(et.hasEventListener('change', 0)).toBe(true);
+      });
+
+      test('target shouldn`t have more listeners than it actually has', () => {
+        expect(et.hasEventListener('change', 1)).toBe(false);
+      });
+
+      test('listener shouldn`t be stored for wrong event type', () => {
+        expect(et.hasEventListener('change2', 0)).toBe(false);
+      });
+
+      test('listener shouldn`t be called third time', () => {
+        et.removeEventListener('change', listener);
+        et.dispatchEvent(event3);
+        expect(listener).toBeCalledTimes(2);
+      });
+
+      afterAll(() => jest.clearAllMocks());
     });
 
-    test('short', () => {
+    describe('Shorthand API', () => {
+      const et = new SyntheticEventTarget();
+
       const event1 = new CustomEvent('change');
       const event2 = new CustomEvent('change');
+
       const listener = jest.fn();
       et.addEventListener(listener);
 
-      expect(listener).toBeCalledTimes(0);
-      et.dispatchEvent(event1);
-      expect(listener).toBeCalledWith(event1);
-      et.removeEventListener(listener);
-      et.dispatchEvent(event2);
-      expect(listener).toBeCalledTimes(1);
+      test('listener shoudn`t be called', () => expect(listener).toBeCalledTimes(0));
+
+      test('listener should be called once', () => {
+        et.dispatchEvent(event1);
+        expect(listener).toBeCalledWith(event1);
+      });
+
+      describe('Listener should be stored', () => {
+        test('should be valid to pass only event type', () => {
+          expect(et.hasEventListener('change')).toBe(true);
+        });
+
+        test('should be valid without passing any of the parameters', () => {
+          expect(et.hasEventListener()).toBe(true);
+        });
+      });
+
+      test('listener shouldn`t be stored for wrong event type', () => {
+        expect(et.hasEventListener('change2')).toBe(false);
+      });
+
+      test('target shouldn`t have more listeners than it actually has', () => {
+        expect(et.hasEventListener(1)).toBe(false);
+      });
+
+      test('listener shouldn`t be called second time', () => {
+        et.removeEventListener(listener);
+        et.dispatchEvent(event2);
+        expect(listener).toBeCalledTimes(1);
+      });
+
+      afterAll(() => jest.clearAllMocks());
     });
 
-    test('legacy fn', () => {
+    describe('Legacy functionality', () => {
+      const et = new SyntheticEventTarget();
+
       const event1 = new CustomEvent('change');
       const event2 = new CustomEvent('change');
       const listener = jest.fn();
       et.addListener(listener);
 
-      expect(listener).toBeCalledTimes(0);
-      et.dispatchEvent(event1);
-      expect(listener).toBeCalledWith(event1);
-      et.removeListener(listener);
-      et.dispatchEvent(event2);
-      expect(listener).toBeCalledTimes(1);
+      test('listener shoudn`t be called', () => expect(listener).toBeCalledTimes(0));
+
+      test('listener should be called once', () => {
+        et.dispatchEvent(event1);
+        expect(listener).toBeCalledWith(event1);
+      });
+
+      test('listener shouldn`t be called second time', () => {
+        et.removeListener(listener);
+        et.dispatchEvent(event2);
+        expect(listener).toBeCalledTimes(1);
+      });
     });
 
-    test('api restriction', () => {
+    describe('API restriction', () => {
+      const et = new SyntheticEventTarget();
       // @ts-ignore
-      expect(() => et.addEventListener()).toThrowError();
-      expect(() => et.addEventListener(null as any)).toThrowError();
-      expect(() => et.addEventListener(1 as any)).toThrowError();
+      test('should fail without params', () => expect(() => et.addEventListener()).toThrowError());
+      test('should fail with null passed as param', () => expect(() => et.addEventListener(null as any)).toThrowError());
+      test('should fail with number passed as param', () => expect(() => et.addEventListener(1 as any)).toThrowError());
+      test('should fail with string passed as callback', () => expect(() => et.addEventListener('change', 'click' as any)).toThrowError());
     });
   });
 
   describe('handler object', () => {
     const et = new SyntheticEventTarget();
 
-    test('basic', () => {
+    describe('Basic functionality', () => {
       const event1 = new CustomEvent('change');
       const event2 = new CustomEvent('change');
       const event3 = new CustomEvent('change');
@@ -69,56 +138,89 @@ describe('dom/events: SyntheticEventTarget', () => {
       };
       et.addEventListener('change', listener);
 
-      expect(listener.handleEvent).toBeCalledTimes(0);
-      et.dispatchEvent(event1);
-      expect(listener.handleEvent).toBeCalledWith(event1);
-      et.dispatchEvent(event2);
-      expect(listener.handleEvent).toHaveBeenLastCalledWith(event2);
-      et.removeEventListener('change', listener);
-      et.dispatchEvent(event3);
-      expect(listener.handleEvent).toBeCalledTimes(2);
+      test('listener event handler shoudn`t be called', () => expect(listener.handleEvent).toBeCalledTimes(0));
+
+      test('listener event handler should be called once', () => {
+        et.dispatchEvent(event1);
+        expect(listener.handleEvent).toBeCalledWith(event1);
+      });
+
+      test('listener event handler should be called second time', () => {
+        et.dispatchEvent(event2);
+        expect(listener.handleEvent).toHaveBeenLastCalledWith(event2);
+      });
+
+      test('listener event handler shouldn`t be called third time', () => {
+        et.removeEventListener('change', listener);
+        et.dispatchEvent(event3);
+        expect(listener.handleEvent).toBeCalledTimes(2);
+      });
     });
 
-    test('short', () => {
+    describe('Short api', () => {
       const event1 = new CustomEvent('change');
       const event2 = new CustomEvent('change');
       const listener = {
         handleEvent: jest.fn()
       };
-      et.addEventListener(listener);
 
-      expect(listener.handleEvent).toBeCalledTimes(0);
-      et.dispatchEvent(event1);
-      expect(listener.handleEvent).toBeCalledWith(event1);
-      et.removeEventListener(listener);
-      et.dispatchEvent(event2);
-      expect(listener.handleEvent).toBeCalledTimes(1);
-    });
+      test('listener event handler shoudn`t be called', () => {
+        et.addEventListener(listener);
+        expect(listener.handleEvent).toBeCalledTimes(0);});
 
-    test('api restriction', () => {
-      expect(() => et.addEventListener({} as any)).toThrowError();
+      test('listener event handler should be called once', () => {
+        et.dispatchEvent(event1);
+        expect(listener.handleEvent).toBeCalledWith(event1);
+      });
+
+      test('listener event handler shouldn`t be called second time', () => {
+        et.removeEventListener(listener);
+        et.dispatchEvent(event2);
+        expect(listener.handleEvent).toBeCalledTimes(1);
+      });
     });
+
+    test('api restriction', () => expect(() => et.addEventListener({} as any)).toThrowError());
   });
 
-  describe('preventDefault', () => {
-    test('not prevented', () => {
+  describe('PreventDefault', () => {
+    describe('Event action not prevented', () => {
       const et = new SyntheticEventTarget();
       const listener = jest.fn(() => {});
-
       et.addEventListener('change', listener);
-      const result = et.dispatchEvent(new Event('change', {cancelable: true}));
-      expect(listener).toBeCalled();
-      expect(result).toBe(false);
+
+      test('event shouldn`t be prevented', () => {
+        const result = et.dispatchEvent(new Event('change', {cancelable: true}));
+        expect(listener).toBeCalled();
+        expect(result).toBe(false);
+      });
     });
 
-    test('prevented', () => {
+    describe('Event action prevented', () => {
       const et = new SyntheticEventTarget();
       const listener = jest.fn((e: Event) => {e.preventDefault();});
+      et.addEventListener('change', listener);
+
+      test('preventDefault method should work correctly', () => {
+        const result = et.dispatchEvent(new Event('change', {cancelable: true}));
+        expect(listener).toBeCalled();
+        expect(result).toBe(true);
+      });
+    });
+  });
+
+  describe('Listener duplicate subscription', () => {
+    const et = new SyntheticEventTarget();
+    const listener = jest.fn(() => {});
+
+    test('event should be subscribed', () => {
+      et.addEventListener('change', listener);
+      expect(et.hasEventListener(1)).toBe(false);
+    });
 
+    test('event shouldn`t be subscribed again', () => {
       et.addEventListener('change', listener);
-      const result = et.dispatchEvent(new Event('change', {cancelable: true}));
-      expect(listener).toBeCalled();
-      expect(result).toBe(true);
+      expect(et.hasEventListener(1)).toBe(false);
     });
   });
 });