Skip to content

Commit

Permalink
feat: support nested event methods
Browse files Browse the repository at this point in the history
  • Loading branch information
andiwils committed May 23, 2024
1 parent 3c928d2 commit c459f47
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 4 deletions.
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v16
64 changes: 64 additions & 0 deletions src/event-mixin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,67 @@ describe('a child class', () => {
expect(eventThreeArgs).toHaveLength(1);
});
});

// New test cases for the path functionality
describe('DeepNestedEvents with path', () => {
class DeepNestedEvents {
eventContainer = {
deeper: {
eventOne: new TypedEvent<(value: number) => void>(),
eventTwo: new TypedEvent<(value: boolean) => void>(),
},
};

fireEventOne() {
this.eventContainer.deeper.eventOne.emit(42);
}

fireEventTwo() {
this.eventContainer.deeper.eventTwo.emit(true);
}
}

const NestedEventsClass = AddEvents<
typeof DeepNestedEvents,
{
eventOne: TypedEvent<(value: number) => void>;
eventTwo: TypedEvent<(value: boolean) => void>;
}
>(DeepNestedEvents, 'eventContainer.deeper');

const myClass = new NestedEventsClass();
it('should notify handlers when nested events are fired', () => {
expect.hasAssertions();
const handlerOneArgs: number[] = [];
const handlerTwoArgs: boolean[] = [];

myClass.on('eventOne', (value: number) => handlerOneArgs.push(value));
myClass.on('eventTwo', (value: boolean) => handlerTwoArgs.push(value));

myClass.fireEventOne();
myClass.fireEventTwo();

expect(handlerOneArgs).toStrictEqual([42]);

expect(handlerTwoArgs).toStrictEqual([true]);
});

it('should throw an error when trying to subscribe to an event with an invalid path', () => {
expect.hasAssertions();

const InvalidPathClass = AddEvents<
typeof DeepNestedEvents,
{
eventOne: TypedEvent<(value: number) => void>;
}
>(DeepNestedEvents, 'eventContainer.unknown');

const instance = new InvalidPathClass();

// Trying to bind an event handler to an invalid path
expect(() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
instance.on('eventOne', () => {});
}).toThrow(new Error('Event "eventContainer.unknown.eventOne" is not defined'));
});
});
43 changes: 39 additions & 4 deletions src/event-mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,45 @@ type eventHandlerType<Type> = Type extends TypedEvent<infer X> ? X : never;
* - The values are of typed 'TypedEvent'.
*
* @param Base - The class which will be extended with event subscription methods.
* @param path - The path to the TypedEvent member in the Base class. This is optional and only needed
* if the events are not directly on the Base class.
* @returns A subclass of Base with event subscription methods added.
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
export function AddEvents<TBase extends Constructor, U>(Base: TBase, path?: string) {
/**
* Get the event object from the given instance. If a path is given, the object will be traversed
* using the path.
*
* @param instance - The instance to get the event object from.
* @returns The event object.
*/
function getEventObject(instance: any) {
if (!path) {
return instance;
}
return path.split('.').reduce((obj, key) => (obj ? obj[key] : undefined), instance);
}

/**
* Get the event object from the given instance. If a path is given, the object will be traversed
* using the path. If the event object is not found, an error will be thrown.
*
* @param instance - The instance to get the event object from.
* @param eventName - The name of the event to get.
* @returns The event.
*/
function getEvent<K extends keyof U>(instance: any, eventName: K) {
const eventsObject = getEventObject(instance);
const event = eventsObject?.[eventName];
if (!event) {
// Construct the full path for the error message
const fullPath = path ? `${path}.${String(eventName)}` : String(eventName);
throw new Error(`Event "${fullPath}" is not defined`);
}
return event;
}

// eslint-disable-next-line jsdoc/require-jsdoc
return class WithEvents extends Base {
/**
Expand All @@ -37,7 +72,7 @@ export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
on<K extends keyof U, E extends eventHandlerType<U[K]>>(eventName: K, handler: E) {
// Even though we bypass type safety in the call (casting this as any), we've enforced it in the
// method signature above, so it's still safe.
(this as any)[eventName].on(handler);
getEvent(this, eventName).on(handler);
}

/**
Expand All @@ -49,7 +84,7 @@ export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
* @param handler - The handler.
*/
once<K extends keyof U, E extends eventHandlerType<U[K]>>(eventName: K, handler: E) {
(this as any)[eventName].once(handler);
getEvent(this, eventName).once(handler);
}

/**
Expand All @@ -61,7 +96,7 @@ export function AddEvents<TBase extends Constructor, U>(Base: TBase) {
* @param handler - The handler.
*/
off<K extends keyof U, E extends eventHandlerType<U[K]>>(eventName: K, handler: E) {
(this as any)[eventName].off(handler);
getEvent(this, eventName).off(handler);
}
};
}
Expand Down

0 comments on commit c459f47

Please sign in to comment.