diff --git a/packages/common/src/services/gridState.service.ts b/packages/common/src/services/gridState.service.ts index 00f373dc3..a7ee44c79 100644 --- a/packages/common/src/services/gridState.service.ts +++ b/packages/common/src/services/gridState.service.ts @@ -392,7 +392,7 @@ export class GridStateService { subscribeToAllGridChanges(grid: SlickGrid) { // Subscribe to Event Emitter of Filter changed this._subscriptions.push( - this.pubSubService.subscribe('onFilterChanged', (currentFilters: CurrentFilter[]) => { + this.pubSubService.subscribe('onFilterChanged', currentFilters => { this.resetRowSelectionWhenRequired(); this.pubSubService.publish('onGridStateChanged', { change: { newValues: currentFilters, type: GridStateType.filter }, gridState: this.getCurrentGridState({ requestRefreshRowFilteredRow: !this.hasRowSelectionEnabled() }) }); @@ -412,7 +412,7 @@ export class GridStateService { // Subscribe to Event Emitter of Sort changed this._subscriptions.push( - this.pubSubService.subscribe('onSortChanged', (currentSorters: CurrentSorter[]) => { + this.pubSubService.subscribe('onSortChanged', currentSorters => { this.resetRowSelectionWhenRequired(); this.pubSubService.publish('onGridStateChanged', { change: { newValues: currentSorters, type: GridStateType.sorter }, gridState: this.getCurrentGridState() }); }) @@ -441,7 +441,7 @@ export class GridStateService { // subscribe to HeaderMenu (hide column) this._subscriptions.push( - this.pubSubService.subscribe('onHeaderMenuHideColumns', (visibleColumns: Column[]) => { + this.pubSubService.subscribe('onHeaderMenuHideColumns', visibleColumns => { const currentColumns: CurrentColumn[] = this.getAssociatedCurrentColumns(visibleColumns); this.pubSubService.publish('onGridStateChanged', { change: { newValues: currentColumns, type: GridStateType.columns }, gridState: this.getCurrentGridState() }); }) @@ -449,14 +449,14 @@ export class GridStateService { // subscribe to Tree Data toggle items changes this._subscriptions.push( - this.pubSubService.subscribe('onTreeItemToggled', (toggleChange: TreeToggleStateChange) => { + this.pubSubService.subscribe('onTreeItemToggled', toggleChange => { this.pubSubService.publish('onGridStateChanged', { change: { newValues: toggleChange, type: GridStateType.treeData }, gridState: this.getCurrentGridState() }); }) ); // subscribe to Tree Data full tree toggle changes this._subscriptions.push( - this.pubSubService.subscribe('onTreeFullToggleEnd', (toggleChange: Omit) => { + this.pubSubService.subscribe>('onTreeFullToggleEnd', toggleChange => { this.pubSubService.publish('onGridStateChanged', { change: { newValues: toggleChange, type: GridStateType.treeData }, gridState: this.getCurrentGridState() }); }) ); diff --git a/packages/common/src/services/pagination.service.ts b/packages/common/src/services/pagination.service.ts index 25512fb90..621fe2d03 100644 --- a/packages/common/src/services/pagination.service.ts +++ b/packages/common/src/services/pagination.service.ts @@ -125,8 +125,8 @@ export class PaginationService { // Subscribe to any dataview row count changed so that when Adding/Deleting item(s) through the DataView // that would trigger a refresh of the pagination numbers if (this.dataView) { - this._subscriptions.push(this.pubSubService.subscribe(`onItemAdded`, (items: any | any[]) => this.processOnItemAddedOrRemoved(items, true))); - this._subscriptions.push(this.pubSubService.subscribe(`onItemDeleted`, (items: any | any[]) => this.processOnItemAddedOrRemoved(items, false))); + this._subscriptions.push(this.pubSubService.subscribe(`onItemAdded`, items => this.processOnItemAddedOrRemoved(items, true))); + this._subscriptions.push(this.pubSubService.subscribe(`onItemDeleted`, items => this.processOnItemAddedOrRemoved(items, false))); } this.refreshPagination(false, false, true); diff --git a/packages/event-pub-sub/src/eventPubSub.service.spec.ts b/packages/event-pub-sub/src/eventPubSub.service.spec.ts index 964483b57..4323454d6 100644 --- a/packages/event-pub-sub/src/eventPubSub.service.spec.ts +++ b/packages/event-pub-sub/src/eventPubSub.service.spec.ts @@ -17,6 +17,15 @@ describe('EventPubSub Service', () => { it('should create the service', () => { expect(service).toBeTruthy(); + expect(service.elementSource).toBeTruthy(); + }); + + it('should be able to change the service element source afterward', () => { + const div = document.createElement('div'); + div.className = 'test'; + service.elementSource = div; + + expect(service.elementSource).toEqual(div); }); describe('publish method', () => { @@ -137,7 +146,7 @@ describe('EventPubSub Service', () => { const mockCallback = jest.fn(); service.eventNamingStyle = EventNamingStyle.kebabCase; - service.subscribeEvent('onClick', mockCallback); + const subscription = service.subscribeEvent('onClick', mockCallback); divContainer.dispatchEvent(new CustomEvent('on-click', { composed: true, detail: { name: 'John' } })); expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); @@ -146,6 +155,9 @@ describe('EventPubSub Service', () => { expect(addEventSpy).toHaveBeenCalledWith('on-click', expect.any(Function)); expect(mockCallback).toHaveBeenCalledTimes(1); // expect(mockCallback).toHaveBeenCalledWith({ detail: { name: 'John' } }); + + subscription.unsubscribe(); + expect(service.subscribedEvents.length).toBe(0); }); }); @@ -157,14 +169,35 @@ describe('EventPubSub Service', () => { service.subscribe('onClick', mockCallback); divContainer.dispatchEvent(new CustomEvent('onClick', { detail: { name: 'John' } })); - service.unsubscribe('onClick', mockCallback); expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); expect(service.subscribedEventNames).toEqual(['onClick']); expect(service.subscribedEvents.length).toBe(1); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + + service.unsubscribe('onClick', mockCallback); expect(removeEventSpy).toHaveBeenCalledWith('onClick', mockCallback); + expect(service.subscribedEvents.length).toBe(0); + }); + + it('should be able to unsubscribe directly from the subscription', () => { + const removeEventSpy = jest.spyOn(divContainer, 'removeEventListener'); + const getEventNameSpy = jest.spyOn(service, 'getEventNameByNamingConvention'); + const mockCallback = jest.fn(); + + const subscription = service.subscribe('onClick', mockCallback); + divContainer.dispatchEvent(new CustomEvent('onClick', { detail: { name: 'John' } })); + + expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); + expect(service.subscribedEventNames).toEqual(['onClick']); + expect(service.subscribedEvents.length).toBe(1); expect(mockCallback).toHaveBeenCalledTimes(1); expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + + subscription.unsubscribe(); + expect(removeEventSpy).toHaveBeenCalledWith('onClick', mockCallback); + expect(service.subscribedEvents.length).toBe(0); }); it('should unsubscribeAll events', () => { @@ -177,17 +210,19 @@ describe('EventPubSub Service', () => { service.subscribe('onClick', mockCallback); service.subscribe('onDblClick', mockDblCallback); divContainer.dispatchEvent(new CustomEvent('onClick', { detail: { name: 'John' } })); - service.unsubscribeAll(); expect(getEventNameSpy).toHaveBeenCalledWith('onClick', ''); expect(getEventNameSpy).toHaveBeenCalledWith('onDblClick', ''); expect(service.subscribedEventNames).toEqual(['onClick', 'onDblClick']); expect(service.subscribedEvents.length).toBe(2); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + + service.unsubscribeAll(); expect(removeEventSpy).toHaveBeenCalledWith('onClick', mockCallback); expect(removeEventSpy).toHaveBeenCalledWith('onDblClick', mockDblCallback); - expect(mockCallback).toHaveBeenCalledTimes(1); expect(unsubscribeSpy).toHaveBeenCalledTimes(2); - expect(mockCallback).toHaveBeenCalledWith({ name: 'John' }); + expect(service.subscribedEvents.length).toBe(0); }); it('should unsubscribe all PubSub by disposing all of them', () => { @@ -201,6 +236,7 @@ describe('EventPubSub Service', () => { expect(mockDispose1).toHaveBeenCalledTimes(1); expect(mockDispose2).toHaveBeenCalledTimes(1); + expect(service.subscribedEvents.length).toBe(0); }); it('should unsubscribe all PubSub by unsubscribing all of them', () => { @@ -214,6 +250,7 @@ describe('EventPubSub Service', () => { expect(mockUnsubscribe1).toHaveBeenCalledTimes(1); expect(mockUnsubscribe2).toHaveBeenCalledTimes(1); + expect(service.subscribedEvents.length).toBe(0); }); }); }); diff --git a/packages/event-pub-sub/src/eventPubSub.service.ts b/packages/event-pub-sub/src/eventPubSub.service.ts index 1fb97c637..4d846c207 100644 --- a/packages/event-pub-sub/src/eventPubSub.service.ts +++ b/packages/event-pub-sub/src/eventPubSub.service.ts @@ -1,8 +1,8 @@ -import { EventNamingStyle, EventSubscription, PubSubService, titleCase, toKebabCase } from '@slickgrid-universal/common'; +import { EventNamingStyle, EventSubscription, PubSubService, Subscription, titleCase, toKebabCase } from '@slickgrid-universal/common'; -interface PubSubEvent { +interface PubSubEvent { name: string; - listener: (event: CustomEventInit) => void; + listener: (event: T | CustomEventInit) => void; } export class EventPubSubService implements PubSubService { @@ -11,6 +11,13 @@ export class EventPubSubService implements PubSubService { eventNamingStyle = EventNamingStyle.camelCase; + get elementSource() { + return this._elementSource; + } + set elementSource(element: Element) { + this._elementSource = element; + } + get subscribedEvents(): PubSubEvent[] { return this._subscribedEvents; } @@ -54,13 +61,18 @@ export class EventPubSubService implements PubSubService { * @param callback The callback to be invoked when the specified message is published. * @return possibly a Subscription */ - subscribe(eventName: string, callback: (data: any) => void): any { + subscribe(eventName: string, callback: (data: T) => void): Subscription { const eventNameByConvention = this.getEventNameByNamingConvention(eventName, ''); // the event listener will return the data in the "event.detail", so we need to return its content to the final callback // basically we substitute the "data" with "event.detail" so that the user ends up with only the "data" result this._elementSource.addEventListener(eventNameByConvention, (event: CustomEventInit) => callback.call(null, event.detail as T)); this._subscribedEvents.push({ name: eventNameByConvention, listener: callback }); + + // return a subscription that we can unsubscribe + return { + unsubscribe: () => this.unsubscribe(eventNameByConvention, callback as never) + }; } /** @@ -70,10 +82,15 @@ export class EventPubSubService implements PubSubService { * @param callback The callback to be invoked when the specified message is published. * @return possibly a Subscription */ - subscribeEvent(eventName: string, listener: (event: CustomEventInit) => void): any | void { + subscribeEvent(eventName: string, listener: (event: CustomEventInit) => void): Subscription { const eventNameByConvention = this.getEventNameByNamingConvention(eventName, ''); this._elementSource.addEventListener(eventNameByConvention, listener); this._subscribedEvents.push({ name: eventNameByConvention, listener }); + + // return a subscription that we can unsubscribe + return { + unsubscribe: () => this.unsubscribe(eventNameByConvention, listener as never) + }; } /** @@ -81,24 +98,29 @@ export class EventPubSubService implements PubSubService { * @param event The event name * @return possibly a Subscription */ - unsubscribe(eventName: string, listener: (event: CustomEventInit) => void) { + unsubscribe(eventName: string, listener: (event: T | CustomEventInit) => void) { const eventNameByConvention = this.getEventNameByNamingConvention(eventName, ''); this._elementSource.removeEventListener(eventNameByConvention, listener); + this.removeSubscribedEventWhenFound(eventName, listener); } /** Unsubscribes all subscriptions that currently exists */ unsubscribeAll(subscriptions?: EventSubscription[]) { if (Array.isArray(subscriptions)) { - for (const subscription of subscriptions) { + let subscription = subscriptions.pop(); + while (subscription) { if (subscription?.dispose) { subscription.dispose(); } else if (subscription?.unsubscribe) { subscription.unsubscribe(); } + subscription = subscriptions.pop(); } } else { - for (const pubSubEvent of this._subscribedEvents) { + let pubSubEvent = this._subscribedEvents.pop(); + while (pubSubEvent) { this.unsubscribe(pubSubEvent.name, pubSubEvent.listener); + pubSubEvent = this._subscribedEvents.pop(); } } } @@ -132,4 +154,11 @@ export class EventPubSubService implements PubSubService { } return outputEventName; } + + protected removeSubscribedEventWhenFound(eventName: string, listener: (event: T | CustomEventInit) => void) { + const eventIdx = this._subscribedEvents.findIndex(evt => evt.name === eventName && evt.listener === listener); + if (eventIdx >= 0) { + this._subscribedEvents.splice(eventIdx, 1); + } + } } diff --git a/packages/pagination-component/src/slick-pagination.component.ts b/packages/pagination-component/src/slick-pagination.component.ts index 259b03ded..5111bc699 100644 --- a/packages/pagination-component/src/slick-pagination.component.ts +++ b/packages/pagination-component/src/slick-pagination.component.ts @@ -53,7 +53,7 @@ export class SlickPaginationComponent { // Anytime the pagination is initialized or has changes, // we'll copy the data into a local object so that we can add binding to this local object this._subscriptions.push( - this.pubSubService.subscribe('onPaginationRefreshed', (paginationChanges: ServicePagination) => { + this.pubSubService.subscribe('onPaginationRefreshed', paginationChanges => { for (const key of Object.keys(paginationChanges)) { (this.currentPagination as any)[key] = (paginationChanges as any)[key]; } diff --git a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts index 58dd80d02..42d4a6051 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -1120,8 +1120,8 @@ export class SlickVanillaGridBundle { this.paginationService.totalItems = this.totalItems; this.paginationService.init(this.slickGrid, paginationOptions, this.backendServiceApi); this.subscriptions.push( - this._eventPubSubService.subscribe('onPaginationChanged', (paginationChanges: ServicePagination) => this.paginationChanged(paginationChanges)), - this._eventPubSubService.subscribe('onPaginationVisibilityChanged', (visibility: { visible: boolean }) => { + this._eventPubSubService.subscribe('onPaginationChanged', paginationChanges => this.paginationChanged(paginationChanges)), + this._eventPubSubService.subscribe<{ visible: boolean; }>('onPaginationVisibilityChanged', visibility => { this.showPagination = visibility?.visible ?? false; if (this.gridOptions?.backendServiceApi) { this.backendUtilityService?.refreshBackendDataset(this.gridOptions);