Skip to content

Commit

Permalink
feat(esl-utils): add provider function support into @prop decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
ala-n committed Apr 17, 2024
1 parent 0bf90fb commit 9f4bfbe
Show file tree
Hide file tree
Showing 2 changed files with 192 additions and 92 deletions.
54 changes: 43 additions & 11 deletions src/modules/esl-utils/decorators/prop.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import type {PropertyProvider} from '../misc/functions';

/** Property configuration */
export type OverrideDecoratorConfig = {
/** To define enumerable property */
enumerable?: boolean;
/** To define readonly property */
readonly?: boolean;
/** To define enumerable property */
enumerable?: boolean;
};

/** Builds getter from provider */
function getter<T>(provider: PropertyProvider<T>) {
return function (this: any): T {
return provider.call(this, this);
};
}

/** Builds own property setter */
function setter(name: string, readonly?: boolean) {
if (readonly) return (): void => void 0;
return function (value: any): void {
Object.defineProperty(this, name, {
value,
writable: true,
configurable: true
});
};
}

/**
* `@prop` is auxiliary decorator to define a field on the prototype level.
*` @prop` can be used to override decorated property from the parent level
Expand All @@ -16,20 +37,31 @@ export type OverrideDecoratorConfig = {
* The class property initial value is a part of object creation, so it goes to the object itself,
* while the @override value is defined on the prototype level.
*
* @param value - value to setup in prototype
* If the value is a provider function, it will be resolved via instance each time property accessed.
*
* @param value - value or PropertyProvider to set up in prototype
* @param prototypeConfig - prototype property configuration
*/
export function prop(value?: any, prototypeConfig: OverrideDecoratorConfig = {}) {
export function prop<T = any>(value?: T | PropertyProvider<T>, prototypeConfig: OverrideDecoratorConfig = {}) {
return function (obj: any, name: string): any {
if (Object.hasOwnProperty.call(obj, name)) {
throw new TypeError('Can\'t override own property');
}
Object.defineProperty(obj, name, {
value,
writable: !prototypeConfig.readonly,
enumerable: !prototypeConfig.enumerable,
configurable: true
});
return {};
if (typeof value === 'function') {
Object.defineProperty(obj, name, {
get: getter(value as PropertyProvider<any>),
set: setter(name, prototypeConfig.readonly),
enumerable: prototypeConfig.enumerable,
configurable: true
});
} else {
Object.defineProperty(obj, name, {
value,
writable: !prototypeConfig.readonly,
enumerable: prototypeConfig.enumerable,
configurable: true
});
}
return {/* To make babel transpiler work */};
};
}
230 changes: 149 additions & 81 deletions src/modules/esl-utils/decorators/test/prop.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,108 @@
import '../../../../polyfills/es5-target-shim';

import {attr, boolAttr, jsonAttr} from '../../../esl-utils/decorators';
import {prop} from '../prop';

describe('Decorator: @prop', () => {
class TestBaseElement extends HTMLElement {
@attr()
public field: string;
@boolAttr()
public field2: boolean;
@jsonAttr()
public field3: {a: number};
@attr()
public field4?: string;
@attr({readonly: true})
public readonlyField: string;
}

describe('Overriding @attr', () => {
test('Declared property can be defined instance based through ES initial value', () => {
class TestClass {
@prop()
public field: string = '123';
}

const el = new TestClass();
expect(el.field).toBe('123');
expect(Object.getOwnPropertyDescriptor(el, 'field')).not.toBeNull();
});

describe('Provider based property resolves value trough it', () => {
const provider = jest.fn();
class TestClass {
@prop(provider)
public field: any;

@prop(provider, {readonly: true})
public readonlyField: any;

@prop(provider, {enumerable: true})
public enumerableField: any;
}

test('If provider function passed - value resolves trough it', () => {
const obj = new TestClass();
provider.mockReturnValue('initial');
expect(obj.field).toBe('initial');
provider.mockReturnValue('new');
expect(obj.field).toBe('new');
});

test('If provider function passed - provider receives instance as a context and argument', () => {
provider.mockImplementation(function() { return this; });

Check warning on line 40 in src/modules/esl-utils/decorators/test/prop.test.ts

View workflow job for this annotation

GitHub Actions / Linting

Missing space before function parentheses
const obj = new TestClass();
expect(obj.field).toBe(obj);
expect(provider).toHaveBeenCalledWith(obj);
});

test('If provider function passed - resolver declared on prototype level', () => {
const obj = new TestClass();
expect(Object.hasOwnProperty.call(obj, 'field')).toBe(false);
expect(Object.getOwnPropertyDescriptor(TestClass.prototype, 'field')).not.toBeNull();
});

test('If provider function passed - value can be overwritten on instance', () => {
const obj = new TestClass();
const obj2 = new TestClass();
provider.mockReturnValue('initial');
obj.field = 'new';
expect(obj.field).toBe('new');
expect(obj2.field).toBe('initial');
});

test('If provider function passed - overwritten value could be cleared with delete', () => {
const obj = new TestClass();
provider.mockReturnValue('initial');
obj.field = 'new';
expect(obj.field).toBe('new');
delete obj.field;
expect(obj.field).toBe('initial');
});

test('If provider function passed - readonly property should ignores overwritten value', () => {
const obj = new TestClass();
provider.mockReturnValue('initial');
obj.readonlyField = 'new';
expect(obj.readonlyField).toBe('initial');
});

test('If provider function passed - property is not enumerable by default', () => {
expect(Object.keys(TestClass.prototype)).not.toContain('field');
});

test('If provider function passed - enumerable property should be enumerable', () => {
expect(Object.keys(TestClass.prototype)).toContain('enumerableField');
});
});

test('Overriding own property produce error', () => {
expect(() => {
class TestElement extends HTMLElement {
@prop({value: ''})
@attr()
public field: string;
}
new TestElement();
}).toThrowError(/own property/);
});

describe('Overriding @attr works fine', () => {
class TestBaseElement extends HTMLElement {
@attr()
public field: string;
@attr()
public field4?: string;
@attr({readonly: true})
public readonlyField: string;
}
class TestElement extends TestBaseElement {
@prop('test')
public override field: string;
Expand All @@ -27,21 +113,21 @@ describe('Decorator: @prop', () => {
}
customElements.define('attr-override-1', TestElement);

test('should override simple @attr decorator', () => {
test('@prop should override simple @attr decorator', () => {
const el = new TestElement();
expect(el.field).toBe('test');
});
test('should override readonly @attr decorator', () => {
test('@prop should override readonly @attr decorator', () => {
const el = new TestElement();
expect(el.readonlyField).toBe('test');
});
test('override should be writeable', () => {
test('@prop override should be writeable', () => {
const el = new TestElement();
el.field = el.readonlyField = 'hi';
expect(el.field).toBe('hi');
expect(el.readonlyField).toBe('hi');
});
test('original decorator should not be executed', () => {
test('Original decorator should not be executed', () => {
const el = new TestElement();
el.field = 'hi';
expect(el.getAttribute('field')).toBe(null);
Expand All @@ -54,100 +140,82 @@ describe('Decorator: @prop', () => {
});
});

describe('Overriding with a non writable', () => {
class TestElement extends TestBaseElement {
@prop('test', {enumerable: true, readonly: true}) public override field: string;
@prop(true, {enumerable: true, readonly: true}) public override field2: boolean;
describe('Overriding @boolAttr works fine', () => {
class TestBaseElement extends HTMLElement {
@boolAttr()
public field: boolean;
}
class TestElement2 extends TestElement {
@prop() public override field: string = 'test2';
@prop(false) public override field2: boolean;
}
customElements.define('attr-writable-override-1', TestElement);
customElements.define('attr-writable-override-2', TestElement2);

test('should override @attr and @boolAttr decorator', () => {
const el = new TestElement();
expect(el.field).toBe('test');
expect(el.field2).toBe(true);
});
test('override should not be writeable', () => {
const el = new TestElement();
expect(() => el.field = 'hi').toThrowError();
expect(() => el.field2 = false).toThrowError();
});
test('should have undefined as a default', () => {
const el = new TestElement2();
expect(el.field).toBe('test2');
expect(el.field2).toBe(false);
});
});

describe('Overriding @boolAttr', () => {
class TestElement extends TestBaseElement {
@prop(true)
public override field2: boolean;
public override field: boolean;
}
customElements.define('bool-attr-override-1', TestElement);

test('should override simple @boolAttr decorator', () => {
test('@prop should override simple @boolAttr decorator', () => {
const el = new TestElement();
expect(el.field2).toBe(true);
expect(el.field).toBe(true);
});
test('override should be writeable', () => {
test('@prop override should be writeable', () => {
const el = new TestElement();
el.field2 = false;
expect(el.field2).toBe(false);
el.field = false;
expect(el.field).toBe(false);
});
test('original decorator should not be executed', () => {
test('Original decorator should not be executed', () => {
const el = new TestElement();
el.field = true;
expect(el.getAttribute('field2')).toBe(null);
});
});

describe('Overriding @jsonAttr', () => {
describe('Overriding @jsonAttr with @prop works fine', () => {
class TestBaseElement extends HTMLElement {
@jsonAttr()
public field: {a: number};
}
class TestElement extends TestBaseElement {
@prop({a: 2})
public override field3: {a: number};
public override field: {a: number};
}
customElements.define('json-attr-override-1', TestElement);

test('should override simple @jsonAttr decorator', () => {
test('@prop should override simple @jsonAttr decorator', () => {
const el = new TestElement();
expect(el.field3).toEqual({a: 2});
expect(el.field).toEqual({a: 2});
});
test('override should be writeable', () => {
test('@prop override should be writeable by default', () => {
const el = new TestElement();
el.field3 = {a: 4};
expect(el.field3).toEqual({a: 4});
el.field = {a: 4};
expect(el.field).toEqual({a: 4});
});
test('original decorator should not be executed', () => {
test('Original decorator should not be executed', () => {
const el = new TestElement();
expect(el.getAttribute('field3')).toBe(null);
el.field = {a: 4};
expect(el.getAttribute('field')).toBe(null);
});
});

describe('Overridden property can be defined through ES initial value ', () => {
describe('Overriding chain, with a non writable @prop', () => {
class TestBaseElement extends HTMLElement {
@attr()
public field: string;
@boolAttr()
public field2: boolean;
}
class TestElement extends TestBaseElement {
@prop()
public override field: string = '123';
@prop('test', {readonly: true}) public override field: string;
@prop(true, {readonly: true}) public override field2: boolean;
}
customElements.define('es-initial-attr-override-1', TestElement);
class TestElement2 extends TestElement {
@prop() public override field: string = 'test2';
@prop(false) public override field2: boolean;
}
customElements.define('attr-writable-override-1', TestElement);
customElements.define('attr-writable-override-2', TestElement2);

test('should override simple @attr decorator', () => {
const el = new TestElement();
expect(el.field).toBe('123');
test('Double overriding works fine', () => {
const el = new TestElement2();
expect(el.field).toBe('test2');
expect(el.field2).toBe(false);
});
});

test('Overriding own property produce error', () => {
expect(() => {
class TestElement extends HTMLElement {
@prop({value: ''})
@attr()
public field: string;
}
new TestElement();
}).toThrowError(/own property/);
});
});

0 comments on commit 9f4bfbe

Please sign in to comment.