Repository: exadel-inc/esl
Pull request #1041 (2022-06-19 10:04:43)

feat: `@attr` v2 (support of serializer/parser)


Patch:
diff --git a/pages/src/navigation/sidebar/sidebar.ts b/pages/src/navigation/sidebar/sidebar.ts
index 91b47b51a..88796c739 100644
--- a/pages/src/navigation/sidebar/sidebar.ts
+++ b/pages/src/navigation/sidebar/sidebar.ts
@@ -1,4 +1,4 @@
-import {attr} from '../../../../src/modules/esl-utils/decorators/attr';
+import {boolAttr} from '../../../../src/modules/esl-utils/decorators/bool-attr';
 import {listen} from '../../../../src/modules/esl-utils/decorators/listen';
 import {prop} from '../../../../src/modules/esl-utils/decorators/prop';
 import {bind} from '../../../../src/modules/esl-utils/decorators/bind';
@@ -22,7 +22,7 @@ export class ESLDemoSidebar extends ESLToggleable {
   @prop() public submenus: string = '.sidebar-nav-secondary';
   @prop() public activeMenuAttr: string = 'data-open';
 
-  @attr({name: 'animation'}) protected _animation: boolean;
+  @boolAttr({name: 'animation'}) protected _animation: boolean;
 
   public get $submenus(): ESLToggleable[] {
     return Array.from(this.querySelectorAll(this.submenus));
diff --git a/src/modules/esl-utils/decorators/attr.ts b/src/modules/esl-utils/decorators/attr.ts
index 4cdb02e3e..56eec5723 100644
--- a/src/modules/esl-utils/decorators/attr.ts
+++ b/src/modules/esl-utils/decorators/attr.ts
@@ -1,9 +1,13 @@
+import {identity} from '../misc/functions';
 import {toKebabCase} from '../misc/format';
 import {getAttr, setAttr} from '../dom/attr';
 import type {AttributeDecorator, AttributeTarget} from '../dom/attr';
 
+export type AttrParser<T> = (attr: string | null) => T;
+export type AttrSerializer<T> = (val: T) => null | boolean | string;
+
 /** HTML attribute mapping configuration */
-type AttrDescriptor = {
+type AttrDescriptor<T = string> = {
   /** HTML attribute name. Uses kebab-cased variable name by default */
   name?: string;
   /** Create getter only */
@@ -11,33 +15,38 @@ type AttrDescriptor = {
   /** Use data-* attribute */
   dataAttr?: boolean;
   /** Default property value. Used if no attribute is present on the element. Empty string by default. */
-  defaultValue?: string | boolean | null;
+  defaultValue?: T;
+  /** Parser from attribute value */
+  parser?: AttrParser<T>;
+  /** Serializer to transform passed value to attribute value */
+  serializer?: AttrSerializer<T>;
 };
 
-function buildSimpleDescriptor(attrName: string, readOnly: boolean, defaultValue: string | boolean | null | undefined): PropertyDescriptor {
-  function get(): string | boolean | null | undefined {
-    return getAttr(this, attrName, defaultValue);
-  }
-
-  function set(value: string | boolean | null | undefined): void {
-    setAttr(this, attrName, value);
-  }
-
-  return readOnly ? {get} : {get, set};
-}
-
 const buildAttrName =
   (propName: string, dataAttr: boolean): string => dataAttr ? `data-${toKebabCase(propName)}` : toKebabCase(propName);
 
+export const toString = (val: string | null): string => val ?? '';
+export const toNumber = parseFloat;
+export const toBoolean = (val: string | null): boolean => val !== null && val !== 'false';
+
 /**
  * Decorator to map current property to element attribute value.
  * Maps string type property.
  * @param config - mapping configuration. See {@link AttrDescriptor}
  */
-export const attr = (config: AttrDescriptor = {}): AttributeDecorator => {
-  config = Object.assign({defaultValue: ''}, config);
+export const attr = <T = string>(config: AttrDescriptor<T> = {}): AttributeDecorator => {
   return (target: Element | AttributeTarget, propName: string): void => {
     const attrName = buildAttrName(config.name || propName, !!config.dataAttr);
-    Object.defineProperty(target, propName, buildSimpleDescriptor(attrName, !!config.readonly, config.defaultValue));
+
+    function get(): T | null {
+      const val = getAttr(this, attrName);
+      if (val === null && 'defaultValue' in config) return config.defaultValue as T;
+      return (config.parser || toString as AttrParser<any>)(val);
+    }
+    function set(value: T): void {
+      setAttr(this, attrName, (config.serializer as AttrSerializer<any> || identity)(value));
+    }
+
+    Object.defineProperty(target, propName, config.readonly ? {get} : {get, set});
   };
 };


Test patch:
diff --git a/src/modules/esl-utils/decorators/test/attr.test.ts b/src/modules/esl-utils/decorators/test/attr.test.ts
index 205d31783..d7e91d8d8 100644
--- a/src/modules/esl-utils/decorators/test/attr.test.ts
+++ b/src/modules/esl-utils/decorators/test/attr.test.ts
@@ -1,5 +1,5 @@
 import '../../../../polyfills/es5-target-shim';
-import {attr} from '../attr';
+import {attr, toBoolean, toNumber} from '../attr';
 
 describe('Decorator: attr', () => {
 
@@ -7,17 +7,22 @@ describe('Decorator: attr', () => {
     @attr()
     public fieldDefault: string | boolean;
     @attr({defaultValue: null})
-    public noDefault: string | boolean;
+    public noDefault: string | boolean | null;
     @attr({dataAttr: true})
-    public dataField: string;
+    public dataField: string | null;
     @attr({name: 'test-attr'})
-    public renamedField: string;
+    public renamedField: string | null;
     @attr({name: 'testAttr', dataAttr: true})
-    public renamedField2: string;
+    public renamedField2: string | null;
     @attr({readonly: true})
-    public readonlyField: string;
+    public readonlyField: string | null;
     @attr({defaultValue: 'def'})
     public defField: string | boolean;
+
+    @attr({parser: toNumber})
+    public attrNumber: number;
+    @attr({parser: toBoolean, serializer: Boolean})
+    public attrBoolean: boolean;
   }
   customElements.define('test-el-attr', TestElement);
   const el = new TestElement();
@@ -101,6 +106,31 @@ describe('Decorator: attr', () => {
     expect(el.hasAttribute('def-field')).toBe(false);
   });
 
+  test('Decorator: attr - number parser', () => {
+    expect(el.attrNumber).toBe(NaN);
+    el.attrNumber = 1;
+    expect(el.attrNumber).toBe(1);
+    expect(el.getAttribute('attr-number')).toBe('1');
+    el.attrNumber = 2;
+    expect(el.attrNumber).toBe(2);
+    expect(el.getAttribute('attr-number')).toBe('2');
+    el.attrNumber = null as any;
+    expect(el.attrNumber).toBe(NaN);
+  });
+
+
+  test('Decorator: attr - boolean parser', () => {
+    expect(el.attrBoolean).toBe(false);
+    el.attrBoolean = true;
+    expect(el.attrBoolean).toBe(true);
+    expect(el.hasAttribute('attr-boolean')).toBe(true);
+    el.attrBoolean = false;
+    expect(el.attrBoolean).toBe(false);
+    expect(el.hasAttribute('attr-boolean')).toBe(false);
+    el.attrBoolean = null as any;
+    expect(el.attrBoolean).toBe(false);
+  });
+
   afterAll(() => {
     document.body.removeChild(el);
   });