Repository: exadel-inc/esl
Pull request #634 (2021-11-12 12:57:51)

feat(esl-utils): array unwrap utility
Function that accepts any value and returns first element of array-like value or returns it back if it's non array-like

github-actions[bot]: ****CLA Assistant Lite bot**** All contributors have signed the CLA  ✍️ 

Patch:
diff --git a/src/modules/esl-utils/misc/array.ts b/src/modules/esl-utils/misc/array.ts
index 5a9e48031..b988324be 100644
--- a/src/modules/esl-utils/misc/array.ts
+++ b/src/modules/esl-utils/misc/array.ts
@@ -1,4 +1,5 @@
 import {identity} from './functions';
+import {isArrayLike} from './object';
 
 type Tuple<T> = [T?, T?];
 
@@ -13,14 +14,23 @@ export const tuple = <T>(arr: T[]): Tuple<T>[] => arr.reduce((acc: Tuple<T>[], e
 export const flat = <T>(arr: (null | T | T[])[]): T[] =>
   arr.reduce((acc: T[], el) => el ? acc.concat(el) : acc, []) as T[];
 
-/** Wrap passed object to array */
+/** Wraps passed object or primitive to array */
 export const wrap = <T>(arr: undefined | null | T | T[]): T[] => {
   if (arr === undefined || arr === null) return [];
   if (Array.isArray(arr)) return arr;
   return [arr];
 };
 
-/** Make array values unique */
+/** Unwraps and returns the first element if passed object is array-like, returns original object otherwise */
+export function unwrap(value: []): undefined;
+export function unwrap<T>(value: (ArrayLike<T> & {0: T}) | T): T;
+export function unwrap<T extends Node>(value: NodeListOf<T>): T | undefined;
+export function unwrap(value: any): any;
+export function unwrap(value: any): any {
+  return isArrayLike(value) ? value[0] : value;
+}
+
+/** Makes array values unique */
 export const uniq = <T> (arr: T[]): T[] => {
   const result: T[] = [];
   const set = new Set<T>();
diff --git a/src/modules/esl-utils/misc/object.ts b/src/modules/esl-utils/misc/object.ts
index ce38f9458..44da161b8 100644
--- a/src/modules/esl-utils/misc/object.ts
+++ b/src/modules/esl-utils/misc/object.ts
@@ -1,10 +1,31 @@
-export const isObject = (obj: any): obj is Record<string, any> => obj && typeof obj === 'object';
+/** Checks that passed value is object, but not a callable-object (function) */
+export const isObject = (obj: any): obj is Record<string, any> => !!obj && typeof obj === 'object';
+/** Checks that passed value is an object or function */
 export const isObjectLike = (obj: any) => isObject(obj) || typeof obj === 'function';
-export const isPrimitive = (obj: any): obj is string | number | boolean =>
-  typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean';
-
+/** Checks if the passed value is primitive */
+export const isPrimitive = (obj: any): obj is string | number | boolean | symbol | undefined | null =>
+  obj === null ||
+  typeof obj === 'undefined' ||
+  typeof obj === 'string' ||
+  typeof obj === 'number' ||
+  typeof obj === 'boolean' ||
+  typeof obj === 'symbol';
+/** Checks that passed object is prototype of some class */
 export const isPrototype = (obj: any) => Object.hasOwnProperty.call(obj, 'constructor');
 
+/** Array-like type definition */
+export type ArrayLike<T = any> = {
+  [key: number]: T;
+  length: number;
+};
+/** Checks that passed object is array-like */
+export const isArrayLike = (value: any): value is ArrayLike => {
+  if (!value || typeof value !== 'object') return false;
+  if (Array.isArray(value)) return true;
+  if (typeof value.length !== 'number' || value.length < 0) return false;
+  return !value.length || Object.hasOwnProperty.call(value, value.length - 1);
+};
+
 export type CopyPredicate = (key: string, value: any) => boolean;
 
 /** Deep object compare */


Test patch:
diff --git a/src/modules/esl-utils/misc/test/array.test.ts b/src/modules/esl-utils/misc/test/array.test.ts
index 0a08ab7b8..292c5a8d0 100644
--- a/src/modules/esl-utils/misc/test/array.test.ts
+++ b/src/modules/esl-utils/misc/test/array.test.ts
@@ -1,4 +1,4 @@
-import {flat, range, tuple, uniq, wrap, groupBy} from '../array';
+import {flat, range, tuple, uniq, wrap, unwrap, groupBy} from '../array';
 
 describe('misc/array helper tests', () => {
   test('tuple', () => {
@@ -44,48 +44,85 @@ describe('misc/array helper tests', () => {
     expect(range(3)).toEqual([0, 1, 2]);
     expect(range(9, (x) => x / 8)).toEqual([...Array(9).keys()].map((x) => x / 8));
   });
-});
 
-type V = string | number;
+  type V = string | number;
+
+  describe('unwrap', () => {
+    document.body.innerHTML = '<div>1</div><div>2</div>';
+
+    test('NodeList to unwrap into first Node', () => {
+      expect(unwrap(document.querySelectorAll('div'))).toEqual(document.querySelector('div'));
+    });
+
+    test.each([
+      [[1], 1],
+      [[1, 2], 1],
+      [[[1, 2], 3], [1, 2]],
+      [[{a: 2}, {a: 2, b:3}, {a: 2, b:3}], {a: 2}]
+    ])('array %p to unwrap into %o', (a: any, expected: any) => {
+      expect(unwrap(a)).toEqual(expected);
+    });
+
+    test.each([
+      [1, 1],
+      [{a: 1, b: 2}, {a: 1, b: 2}],
+      [{0: 1, 1: 2, length: 2}, 1],
+      [{length: 2}, {length: 2}]
+    ])('non-array value %p to unwrap into %o', (a: any, expected: any) => {
+      expect(unwrap(a)).toEqual(expected);
+    });
+
+    test.each([
+      [undefined],
+      [[]]
+    ])('value %p to unwrap into undefined', (a: any) => {
+      expect(unwrap(a)).toEqual(undefined);
+    });
+
+    test('null to be returned', () => {
+      expect(unwrap(null)).toEqual(null);
+    });
+  });
 
-describe('misc/groupBy', () => {
-  test.each([
-    [
-      [1, 2, 10, 4, 3],
-      (i: number) => i % 2 > 0 ? 'odd' : 'even',
-      {odd: [1, 3], even: [2, 10, 4]}
-    ],
-    [
-      ['cat', 'fat', 'kitten'],
-      (i: string) => i.length,
-      {3: ['cat', 'fat'], 6: ['kitten']}
-    ],
-    [
-      ['Fog', 'Tea', 'ocean', 'rain'],
-      (i: string) => i[0] === i[0].toUpperCase() ? 'uppercase' : 'lowercase',
-      {uppercase: ['Fog', 'Tea'], lowercase: ['ocean', 'rain']}
-    ],
-    [
-      [{a: 2, b: 1}, {b: 2}, {a: 1}, {}],
-      (i: Record<string, number>) => i['a'] ? i['a'] : 'undefined',
-      {2: [{a: 2, b: 1}], undefined: [{b: 2}, {}], 1: [{a: 1}]}
-    ],
-    [
-      [100, 20, 10, 101, 300],
-      (i: number) => String(i)[0],
-      {'1': [100, 10, 101], '2': [20], '3': [300]}
-    ],
-    [
-      [],
-      (i: any) => i,
-      {}
-    ],
-    [
-      [[5, 2], [1, 0], [5, 4]],
-      (i: number[]) => i[0],
-      {5: [[5, 2], [5, 4]], 1: [[1, 0]]}
-    ]
-  ])('array %p with criteria %p to transform into %o', (a: any[], b: (t: any) => V, expected: Record<V, any>) => {
-    expect(groupBy(a, b)).toEqual(expected);
+  describe('groupBy', () => {
+    test.each([
+      [
+        [1, 2, 10, 4, 3],
+        (i: number) => i % 2 > 0 ? 'odd' : 'even',
+        {odd: [1, 3], even: [2, 10, 4]}
+      ],
+      [
+        ['cat', 'fat', 'kitten'],
+        (i: string) => i.length,
+        {3: ['cat', 'fat'], 6: ['kitten']}
+      ],
+      [
+        ['Fog', 'Tea', 'ocean', 'rain'],
+        (i: string) => i[0] === i[0].toUpperCase() ? 'uppercase' : 'lowercase',
+        {uppercase: ['Fog', 'Tea'], lowercase: ['ocean', 'rain']}
+      ],
+      [
+        [{a: 2, b: 1}, {b: 2}, {a: 1}, {}],
+        (i: Record<string, number>) => i['a'] ? i['a'] : 'undefined',
+        {2: [{a: 2, b: 1}], undefined: [{b: 2}, {}], 1: [{a: 1}]}
+      ],
+      [
+        [100, 20, 10, 101, 300],
+        (i: number) => String(i)[0],
+        {'1': [100, 10, 101], '2': [20], '3': [300]}
+      ],
+      [
+        [],
+        (i: any) => i,
+        {}
+      ],
+      [
+        [[5, 2], [1, 0], [5, 4]],
+        (i: number[]) => i[0],
+        {5: [[5, 2], [5, 4]], 1: [[1, 0]]}
+      ]
+    ])('array %p with criteria %p to transform into %o', (a: any[], b: (t: any) => V, expected: Record<V, any>) => {
+      expect(groupBy(a, b)).toEqual(expected);
+    });
   });
 });
diff --git a/src/modules/esl-utils/misc/test/object.test.ts b/src/modules/esl-utils/misc/test/object.test.ts
index e44c9f725..1d3448fda 100644
--- a/src/modules/esl-utils/misc/test/object.test.ts
+++ b/src/modules/esl-utils/misc/test/object.test.ts
@@ -1,6 +1,63 @@
-import {defined, deepCompare, getPropertyDescriptor, get, set, copyDefinedKeys, copy, omit, deepMerge} from '../object';
+import {
+  defined,
+  deepCompare,
+  getPropertyDescriptor,
+  get,
+  set,
+  copyDefinedKeys,
+  copy,
+  omit,
+  deepMerge,
+  isObject,
+  isObjectLike,
+  isPrimitive,
+  isPrototype,
+  isArrayLike
+} from '../object';
 
 describe('misc/object', () => {
+  describe('Type Guards', () => {
+    test('isObject', () => {
+      expect(isObject(undefined)).toBe(false);
+      expect(isObject(null)).toBe(false);
+      expect(isObject('1')).toBe(false);
+      expect(isObject({})).toBe(true);
+      expect(isObject([])).toBe(true);
+      expect(isObject(() => true)).toBe(false);
+    });
+    test('isObjectLike', () => {
+      expect(isObjectLike(null)).toBe(false);
+      expect(isObjectLike({})).toBe(true);
+      expect(isObjectLike([])).toBe(true);
+      expect(isObjectLike(() => true)).toBe(true);
+    });
+    test('isPrimitive', () => {
+      expect(isPrimitive(undefined)).toBe(true);
+      expect(isPrimitive(null)).toBe(true);
+      expect(isPrimitive(0)).toBe(true);
+      expect(isPrimitive('')).toBe(true);
+      expect(isPrimitive(Symbol())).toBe(true);
+      expect(isPrimitive({})).toBe(false);
+      expect(isPrimitive(() => true)).toBe(false);
+    });
+    test('isPrototype', () => {
+      expect(isPrototype({})).toBe(false);
+      class Test {}
+      expect(isPrototype(Test)).toBe(false);
+      expect(isPrototype(Test.prototype)).toBe(true);
+      expect(isPrototype(Array)).toBe(false);
+      expect(isPrototype(Array.prototype)).toBe(true);
+    });
+    test('isArrayLike', () => {
+      expect(isArrayLike({})).toBe(false);
+      expect(isArrayLike([])).toBe(true);
+      expect(isArrayLike([1])).toBe(true);
+      expect(isArrayLike({length: 0})).toBe(true);
+      expect(isArrayLike({length: 1, 0: null})).toBe(true);
+      expect(isArrayLike(document.querySelectorAll('*'))).toBe(true);
+    });
+  });
+
   describe('deepCompare', () => {
     test.each([
       [null, null],
@@ -40,7 +97,7 @@ describe('misc/object', () => {
   });
 
   describe('getPropertyDescriptor', () => {
-    test('simple', ()=> {
+    test('simple', () => {
       const object = {
         a: 1,
         get b() { return 2; }
@@ -59,7 +116,7 @@ describe('misc/object', () => {
       expect(getPropertyDescriptor(object, 'f')).toBeFalsy();
     });
 
-    test('1 lvl', ()=> {
+    test('1 lvl', () => {
       class A {
         a = 1;
         b() { return 2; }
@@ -73,7 +130,7 @@ describe('misc/object', () => {
       expect(getPropertyDescriptor(obj, 'c')).toHaveProperty('get');
     });
 
-    test('2 lvl', ()=> {
+    test('2 lvl', () => {
       class A {
         a() { return 3; }
         get b() { return 3; }
@@ -206,7 +263,7 @@ describe('misc/object', () => {
       expect(deepMerge(...args)).toEqual(result);
     });
 
-    test('merge array + object', ()  => {
+    test('merge array + object', () => {
       const res = deepMerge([1, 2], {a: 1});
       const exp: any = [1, 2];
       exp.a = 1;