Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bindMethods() method and a mixin() decorator (TS) #37

Merged
merged 9 commits into from Jun 15, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 33 additions & 0 deletions index.d.ts
@@ -1,4 +1,21 @@
declare class Emittery {
/**
* In TypeScript, returns a decorator which mixins `Emittery` as property `emitteryPropertyName` and `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the target class.
*
* @example
* ```
* import Emittery = require('emittery');
*
* @Emittery.mixin('emittery')
* class MyClass {}
*
* let instance = new MyClass();
*
* instance.emit('event');
* ```
*/
static mixin(emitteryPropertyName: string, methodNames?: readonly string[]): Function;

/**
* Subscribe to an event.
*
Expand Down Expand Up @@ -74,6 +91,22 @@ declare class Emittery {
* specified.
*/
listenerCount(eventName?: string): number;

/**
* Bind the given `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the `target` object.
*
* @example
* ```
* import Emittery = require('emittery');
*
* let object = {};
*
* new Emittery().bindMethods(object);
*
* object.emit('event');
* ```
*/
bindMethods(target: object, methodNames?: readonly string[]): void;
}

declare namespace Emittery {
Expand Down
84 changes: 84 additions & 0 deletions index.js
Expand Up @@ -25,7 +25,70 @@ function getListeners(instance, eventName) {
return events.get(eventName);
}

function defaultMethodNamesOrAssert(methodNames) {
if (methodNames === undefined) {
return allEmitteryMethods;
}

if (!Array.isArray(methodNames)) {
throw new TypeError('`methodNames` must be an array of strings');
}

for (const methodName of methodNames) {
if (!allEmitteryMethods.includes(methodName)) {
if (typeof methodName !== 'string') {
throw new TypeError('`methodNames` element must be a string');
}

throw new Error(`${methodName} is not Emittery method`);
}
}

return methodNames;
}

class Emittery {
static mixin(emitteryPropertyName, methodNames) {
methodNames = defaultMethodNamesOrAssert(methodNames);
return target => {
if (typeof target !== 'function') {
throw new TypeError('`target` must be function');
}

for (const methodName of methodNames) {
if (target.prototype[methodName] !== undefined) {
throw new Error(`The property \`${methodName}\` already exists on \`target\``);
}
}

function getEmitteryProperty() {
Object.defineProperty(this, emitteryPropertyName, {
enumerable: false,
value: new Emittery()
});
return this[emitteryPropertyName];
}

Object.defineProperty(target.prototype, emitteryPropertyName, {
enumerable: false,
get: getEmitteryProperty
});

const emitteryMethodCaller = methodName => function (...args) {
return this[emitteryPropertyName][methodName](...args);
};

for (const methodName of methodNames) {
Object.defineProperty(target.prototype, methodName, {
enumerable: false,
value: emitteryMethodCaller(methodName)
});
}

return target;
};
}

constructor() {
anyMap.set(this, new Set());
eventsMap.set(this, new Map());
Expand Down Expand Up @@ -140,8 +203,29 @@ class Emittery {

return count;
}

bindMethods(target, methodNames) {
if (typeof target !== 'object' || target === null) {
throw new TypeError('`target` must be an object');
}

methodNames = defaultMethodNamesOrAssert(methodNames);

for (const methodName of methodNames) {
if (target[methodName] !== undefined) {
throw new Error(`The property \`${methodName}\` already exists on \`target\``);
}

Object.defineProperty(target, methodName, {
enumerable: false,
value: this[methodName].bind(this)
});
}
}
}

const allEmitteryMethods = Object.getOwnPropertyNames(Emittery.prototype).filter(v => v !== 'constructor');

// Subclass used to encourage TS users to type their events.
Emittery.Typed = class extends Emittery {};
Object.defineProperty(Emittery.Typed, 'Typed', {
Expand Down
29 changes: 29 additions & 0 deletions readme.md
Expand Up @@ -103,6 +103,20 @@ If `eventName` is given, only the listeners for that event are cleared.

The number of listeners for the `eventName` or all events if not specified.

#### bindMethods(target, methodNames?)

Bind the given `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the `target` object.

```js
import Emittery = require('emittery');

let object = {};

new Emittery().bindMethods(object);

object.emit('event');
```


## TypeScript

Expand All @@ -119,6 +133,21 @@ ee.emit('value', 1); // TS compilation error
ee.emit('end'); // TS compilation error
```

### Emittery.mixin(emitteryPropertyName, methodNames?)

A decorator which mixins `Emittery` as property `emitteryPropertyName` and `methodNames`, or all `Emittery` methods if `methodNames` is not defined, into the target class.

```ts
import Emittery = require('emittery');

@Emittery.mixin('emittery')
class MyClass {}

let instance = new MyClass();

instance.emit('event');
```


## Scheduling details

Expand Down
140 changes: 140 additions & 0 deletions test/index.js
Expand Up @@ -411,3 +411,143 @@ test('listenerCount() - eventName must be undefined if not a string', t => {
emitter.listenerCount(42);
}, TypeError);
});

test('bindMethods()', t => {
const methodsToBind = ['on', 'off', 'emit', 'listenerCount'];

const emitter = new Emittery();
const target = {};

const oldPropertyNames = Object.getOwnPropertyNames(target);
emitter.bindMethods(target, methodsToBind);

t.deepEqual(Object.getOwnPropertyNames(target).sort(), oldPropertyNames.concat(methodsToBind).sort());

for (const method of methodsToBind) {
t.is(typeof target[method], 'function');
}

t.is(target.listenerCount(), 0);
});

test('bindMethods() - methodNames must be array of strings or undefined', t => {
t.throws(() => {
new Emittery().bindMethods({}, null);
});

t.throws(() => {
new Emittery().bindMethods({}, 'string');
});

t.throws(() => {
new Emittery().bindMethods({}, {});
});

t.throws(() => {
new Emittery().bindMethods({}, [null]);
});

t.throws(() => {
new Emittery().bindMethods({}, [1]);
});

t.throws(() => {
new Emittery().bindMethods({}, [{}]);
});
});

test('bindMethods() - must bind all methods if no array supplied', t => {
const methodsExpected = ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'];

const emitter = new Emittery();
const target = {};

const oldPropertyNames = Object.getOwnPropertyNames(target);
emitter.bindMethods(target);

t.deepEqual(Object.getOwnPropertyNames(target).sort(), oldPropertyNames.concat(methodsExpected).sort());

for (const method of methodsExpected) {
t.is(typeof target[method], 'function');
}

t.is(target.listenerCount(), 0);
});

test('bindMethods() - methodNames must only include Emittery methods', t => {
const emitter = new Emittery();
const target = {};
t.throws(() => emitter.bindMethods(target, ['noexistent']));
});

test('bindMethods() - must not set already existing fields', t => {
const emitter = new Emittery();
const target = {
on: true
};
t.throws(() => emitter.bindMethods(target, ['on']));
});

test('bindMethods() - target must be an object', t => {
const emitter = new Emittery();
t.throws(() => emitter.bindMethods('string', []));
t.throws(() => emitter.bindMethods(null, []));
t.throws(() => emitter.bindMethods(undefined, []));
});

test('mixin()', t => {
class TestClass {
constructor(v) {
this.v = v;
}
}
const TestClassWithMixin = Emittery.mixin('emitter', ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'])(TestClass);
const symbol = Symbol('test symbol');
const instance = new TestClassWithMixin(symbol);
t.true(instance.emitter instanceof Emittery);
t.true(instance instanceof TestClass);
t.is(instance.emitter, instance.emitter);
t.is(instance.v, symbol);
t.is(instance.listenerCount(), 0);
});

test('mixin() - methodNames must be array of strings or undefined', t => {
class TestClass {
}
t.throws(() => Emittery.mixin('emitter', null)(TestClass));
t.throws(() => Emittery.mixin('emitter', 'string')(TestClass));
t.throws(() => Emittery.mixin('emitter', {})(TestClass));
t.throws(() => Emittery.mixin('emitter', [null])(TestClass));
t.throws(() => Emittery.mixin('emitter', [1])(TestClass));
t.throws(() => Emittery.mixin('emitter', [{}])(TestClass));
});

test('mixin() - must mixin all methods if no array supplied', t => {
const methodsExpected = ['on', 'off', 'once', 'emit', 'emitSerial', 'onAny', 'offAny', 'clearListeners', 'listenerCount', 'bindMethods'];

class TestClass {}
const TestClassWithMixin = Emittery.mixin('emitter')(TestClass);

t.deepEqual(Object.getOwnPropertyNames(TestClassWithMixin.prototype).sort(), methodsExpected.concat(['constructor', 'emitter']).sort());
});

test('mixin() - methodNames must only include Emittery methods', t => {
class TestClass {}
t.throws(() => Emittery.mixin('emitter', ['nonexistent'])(TestClass));
});

test('mixin() - must not set already existing methods', t => {
class TestClass {
on() {
return true;
}
}
t.throws(() => Emittery.mixin('emitter', ['on'])(TestClass));
});

test('mixin() - target must be function', t => {
t.throws(() => Emittery.mixin('emitter')('string'));
t.throws(() => Emittery.mixin('emitter')(null));
t.throws(() => Emittery.mixin('emitter')(undefined));
t.throws(() => Emittery.mixin('emitter')({}));
});