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

feat: support nested event methods #5

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for consistency, can you extract these out into an interface like the test above does? with them matching it makes it easier to see that what's different here is the path argument.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing. Done.

>(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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment looks like it isn't really relevant here anymore...maybe better as a note on getEvent?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Done.

(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
Loading