diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..5edcff0 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v16 \ No newline at end of file diff --git a/README.md b/README.md index a6b30c3..2e1173c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,60 @@ export const Parent = _Parent & WithEventsDummyType(ParentEvents); ``` This type (`Parent`) is what should be exported and used by other code (_not_ `_Parent`). +### Specifying Paths to Nested Event Locations with `AddEvents` + +To accommodate complex class structures where events are organized within nested objects, the `AddEvents` utility allows specifying a path to these nested event locations. This feature enhances the ability to manage events in a structured manner within classes. + +#### Enhancing Classes with Nested Events + +For classes where events are nested within an object, you can specify the path to these nested events when enhancing your class. This approach helps maintain organized and encapsulated event structures. + +**Example: Setting Up Nested Events** + +Consider a class with events nested under a property called `eventsContainer`: + +```typescript +// Define an interface +interface NestedEvents { + eventOne: TypedEvent<() => void>; +} + +// Define a class with nested events +class _Nested { + eventsContainer = { + eventOne: new TypedEvent<() => void>() + }; +} + +// Enhance the class by specifying the path to nested events +export const Nested = AddEvents(_Nested, 'eventsContainer'); + +// Export it as a type as well, to avoid "'Nested' refers to a value, but is being used as a type". +export const Nested = _Nested & WithEventsDummyType(NestedEvents); + +// Create an instance and attach event listeners +const instance = new Nested(); +instance.on('eventOne', () => console.log('Event one triggered')); +instance.eventsContainer.eventOne.emit(); +``` + +#### Error Handling for Incorrect Paths + +If an incorrect path is provided, the utility throws an error. This ensures your event paths are correctly configured and provides immediate feedback for debugging. + +**Example: Incorrect Path Configuration** + +Here’s how errors are managed when an incorrect path is specified: + +```typescript +try { + const InvalidClass = AddEvents(_Nested, 'incorrectPath'); + const wrongInstance = new InvalidClass(); + wrongInstance.on('eventOne', () => console.log('This will never run')); +} catch (error) { + console.error(error); // Outputs: Error: Event "incorrectPath.eventOne" is not defined +} +``` ## FAQ ##### _Rather than requiring constraints on the type `U` in AddEvents via documentation, why not express them in the type system?_ diff --git a/src/event-mixin.spec.ts b/src/event-mixin.spec.ts index 36a4e60..7f0c1a6 100644 --- a/src/event-mixin.spec.ts +++ b/src/event-mixin.spec.ts @@ -76,3 +76,62 @@ describe('a child class', () => { expect(eventThreeArgs).toHaveLength(1); }); }); + +interface NestedEvents { + eventOne: TypedEvent<(value: number) => void>; + eventTwo: TypedEvent<(value: boolean) => void>; +} + +interface InvalidPathEvents { + eventOne: TypedEvent<(value: number) => void>; +} +class _Nested { + 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 Nested = AddEvents(_Nested, 'eventContainer.deeper'); +const InvalidPath = AddEvents(_Nested, 'eventContainer.unknown'); + +describe('a nested class with path', () => { + const myClass = new Nested(); + 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 instance = new InvalidPath(); + + // 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')); + }); +}); diff --git a/src/event-mixin.ts b/src/event-mixin.ts index b3b0a72..657168a 100644 --- a/src/event-mixin.ts +++ b/src/event-mixin.ts @@ -22,10 +22,47 @@ type eventHandlerType = Type extends TypedEvent ? 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(Base: TBase) { +export function AddEvents(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. Even though we bypass + * type safety in the call (casting instance as any), we've enforced it in the method signature + * above, so it's still safe. + * + * @param instance - The instance to get the event object from. + * @param eventName - The name of the event to get. + * @returns The event. + */ + function getEvent(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 { /** @@ -35,9 +72,7 @@ export function AddEvents(Base: TBase) { * @param handler - The handler to invoke when the event is fired. */ on>(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); } /** @@ -49,7 +84,7 @@ export function AddEvents(Base: TBase) { * @param handler - The handler. */ once>(eventName: K, handler: E) { - (this as any)[eventName].once(handler); + getEvent(this, eventName).once(handler); } /** @@ -61,7 +96,7 @@ export function AddEvents(Base: TBase) { * @param handler - The handler. */ off>(eventName: K, handler: E) { - (this as any)[eventName].off(handler); + getEvent(this, eventName).off(handler); } }; }