From 9f071e49a7ea0a91ccfb9e219343b9fd4a369719 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 9 Sep 2024 16:04:33 +0200 Subject: [PATCH] test: add DialogManager tests --- src/components/Dialog/DialogsManager.ts | 56 ++- .../Dialog/__tests__/DialogsManager.test.js | 347 ++++++++++++++++++ src/components/Dialog/hooks/useDialog.ts | 4 +- 3 files changed, 386 insertions(+), 21 deletions(-) create mode 100644 src/components/Dialog/__tests__/DialogsManager.test.js diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogsManager.ts index c15a7052d9..becf6d39ce 100644 --- a/src/components/Dialog/DialogsManager.ts +++ b/src/components/Dialog/DialogsManager.ts @@ -2,7 +2,6 @@ type DialogId = string; export type GetOrCreateParams = { id: DialogId; - isOpen?: boolean; }; export type Dialog = { @@ -15,7 +14,7 @@ export type Dialog = { toggleSingle: () => void; }; -type DialogEvent = { type: 'close' | 'open' | 'openCountChange' }; +type DialogEvent = { type: 'close' | 'open' }; const dialogsManagerEvents = ['openCountChange'] as const; type DialogsManagerEvent = { type: typeof dialogsManagerEvents[number] }; @@ -46,7 +45,7 @@ export class DialogsManager { this.id = id ?? new Date().getTime().toString(); } - getOrCreate({ id, isOpen = false }: GetOrCreateParams) { + getOrCreate({ id }: GetOrCreateParams) { let dialog = this.dialogs[id]; if (!dialog) { dialog = { @@ -54,7 +53,7 @@ export class DialogsManager { this.close(id); }, id, - isOpen, + isOpen: false, open: () => { this.open({ id }); }, @@ -88,10 +87,11 @@ export class DialogsManager { if (!id) return noop; if (!this.dialogEventListeners[id]) { - this.dialogEventListeners[id] = { close: [], open: [] }; + this.dialogEventListeners[id] = {}; } - this.dialogEventListeners[id][eventType] = [ - ...(this.dialogEventListeners[id][eventType] ?? []), + + this.dialogEventListeners[id][eventType as DialogEvent['type']] = [ + ...(this.dialogEventListeners[id][eventType as DialogEvent['type']] ?? []), listener as DialogEventHandler, ]; return () => { @@ -104,18 +104,33 @@ export class DialogsManager { { id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId }, ) { if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) { - const eventListeners = this.dialogsManagerEventListeners[ + if (!this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']]?.length) + return; + + this.dialogsManagerEventListeners[ eventType as DialogsManagerEvent['type'] - ]; - eventListeners?.filter((l) => l !== listener); + ] = this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']]?.filter( + (l) => l !== listener, + ); return; } if (!id) return; - const eventListeners = this.dialogEventListeners[id]?.[eventType]; + const eventListeners = this.dialogEventListeners[id]?.[eventType as DialogEvent['type']]; if (!eventListeners) return; - this.dialogEventListeners[id][eventType] = eventListeners.filter((l) => l !== listener); + + this.dialogEventListeners[id][eventType as DialogEvent['type']] = eventListeners.filter( + (l) => l !== listener, + ); + + if (!this.dialogEventListeners[id][eventType as DialogEvent['type']]?.length) { + delete this.dialogEventListeners[id][eventType as DialogEvent['type']]; + } + + if (!Object.keys(this.dialogEventListeners[id]).length) { + delete this.dialogEventListeners[id]; + } } open(params: GetOrCreateParams, single?: boolean) { @@ -127,7 +142,7 @@ export class DialogsManager { this.dialogs[params.id].isOpen = true; this.openDialogCount++; this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this)); - this.dialogEventListeners[params.id].open?.forEach((listener) => listener(dialog)); + this.dialogEventListeners[params.id]?.open?.forEach((listener) => listener(dialog)); } close(id: DialogId) { @@ -135,7 +150,7 @@ export class DialogsManager { if (!dialog?.isOpen) return; dialog.isOpen = false; this.openDialogCount--; - this.dialogEventListeners[id].close?.forEach((listener) => listener(dialog)); + this.dialogEventListeners[id]?.close?.forEach((listener) => listener(dialog)); this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this)); } @@ -144,7 +159,7 @@ export class DialogsManager { } toggleOpen(params: GetOrCreateParams) { - if (this.dialogs[params.id].isOpen) { + if (this.dialogs[params.id]?.isOpen) { this.close(params.id); } else { this.open(params); @@ -152,7 +167,7 @@ export class DialogsManager { } toggleOpenSingle(params: GetOrCreateParams) { - if (this.dialogs[params.id].isOpen) { + if (this.dialogs[params.id]?.isOpen) { this.close(params.id); } else { this.open(params, true); @@ -160,8 +175,8 @@ export class DialogsManager { } remove(id: DialogId) { - const dialogs = { ...this.dialogs }; - if (!dialogs[id]) return; + const dialog = this.dialogs[id]; + if (!dialog) return; const countListeners = !!this.dialogEventListeners[id] && @@ -172,7 +187,10 @@ export class DialogsManager { if (!countListeners) { delete this.dialogEventListeners[id]; - delete dialogs[id]; + if (dialog.isOpen) { + this.openDialogCount--; + } + delete this.dialogs[id]; } } } diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js new file mode 100644 index 0000000000..dd7228ad1d --- /dev/null +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -0,0 +1,347 @@ +import { DialogsManager } from '../DialogsManager'; + +const dialogId = 'dialogId'; + +describe('DialogManager', () => { + it('initiates with provided options', () => { + const id = 'XX'; + const dm = new DialogsManager({ id }); + expect(dm.id).toBe(id); + }); + it('initiates with default options', () => { + const mockedId = '12345'; + const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId); + const dm = new DialogsManager(); + expect(dm.id).toBe(mockedId); + spy.mockRestore(); + }); + it('creates a new closed dialog', () => { + const dm = new DialogsManager(); + expect(Object.keys(dm.dialogs)).toHaveLength(0); + expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ + close: expect.any(Function), + id: 'dialogId', + isOpen: false, + open: expect.any(Function), + remove: expect.any(Function), + toggle: expect.any(Function), + toggleSingle: expect.any(Function), + }); + expect(Object.keys(dm.dialogs)).toHaveLength(1); + expect(dm.openDialogCount).toBe(0); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + expect(Object.keys(dm.dialogsManagerEventListeners)).toHaveLength(1); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + }); + + it('retrieves an existing dialog', () => { + const dm = new DialogsManager(); + dm.dialogs[dialogId] = { id: dialogId, isOpen: true }; + expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ + id: 'dialogId', + isOpen: true, + }); + }); + + it('registers dialog event listener for non-existent dialog', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('open', { id: dialogId, listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1); + expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1); + expect(dm.dialogEventListeners[dialogId].close).toBeUndefined(); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + + dm.on('close', { id: dialogId, listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1); + expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(2); + expect(dm.dialogEventListeners[dialogId].open).toHaveLength(1); + expect(dm.dialogEventListeners[dialogId].close).toHaveLength(1); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + }); + it('registers dialog event listener for existing dialog', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('open', { id: dialogId, listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1); + expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1); + expect(dm.dialogEventListeners[dialogId].close).toBeUndefined(); + expect(dm.dialogEventListeners[dialogId].open).toHaveLength(1); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + + dm.on('close', { id: dialogId, listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1); + expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(2); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + expect(Object.keys(dm.dialogEventListeners[dialogId].close)).toHaveLength(1); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + }); + + it('registers dialog manager event listener', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + + dm.on('openCountChange', { listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(1); + }); + + it('does not register dialog event listener without dialog id', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('open', { listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('close', { listener }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + }); + + it('unregisters dialog event listener for non-existent dialog', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const dm = new DialogsManager(); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('open', { id: dialogId, listener: listener1 }); + dm.on('open', { id: dialogId, listener: listener2 }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2); + dm.off('open', { id: dialogId, listener: listener1 }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + + const unsubscribe = dm.on('open', { id: dialogId, listener: listener1 }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2); + unsubscribe(); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + dm.off('open', { id: dialogId, listener: listener2 }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + }); + + it('unregisters dialog event listener for existing dialog', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('open', { id: dialogId, listener: listener1 }); + dm.on('open', { id: dialogId, listener: listener2 }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2); + dm.off('open', { id: dialogId, listener: listener1 }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + + const unsubscribe = dm.on('open', { id: dialogId, listener: listener1 }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2); + unsubscribe(); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + dm.off('open', { id: dialogId, listener: listener2 }); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + }); + + it('does not unregister dialog event listener without dialog id', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + + dm.on('open', { id: dialogId, listener }); + dm.off('open', { listener }); + expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1); + }); + + it('unregisters dialog manager event listener', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + + const unsubscribe = dm.on('openCountChange', { listener: listener1 }); + dm.on('openCountChange', { listener: listener2 }); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(2); + dm.off('openCountChange', { listener: listener2 }); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(1); + unsubscribe(); + expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0); + }); + + it('executes all the dialog event listeners', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const dm = new DialogsManager(); + dm.on('open', { id: dialogId, listener: listener1 }); + dm.on('close', { id: dialogId, listener: listener2 }); + dm.open({ id: dialogId }); + dm.close(dialogId); + expect(listener1).toHaveBeenCalledWith(dm.dialogs[dialogId]); + expect(listener2).toHaveBeenCalledWith(dm.dialogs[dialogId]); + }); + + it('executes all the dialog manager event listeners', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const dm = new DialogsManager(); + dm.on('openCountChange', { listener: listener1 }); + dm.on('openCountChange', { listener: listener2 }); + dm.open({ id: dialogId }); + expect(listener1).toHaveBeenCalledWith(dm); + expect(listener2).toHaveBeenCalledWith(dm); + }); + + it('creates a dialog if it does not exist on open', () => { + const dm = new DialogsManager(); + dm.open({ id: dialogId }); + expect(dm.dialogs[dialogId]).toMatchObject({ + close: expect.any(Function), + id: 'dialogId', + isOpen: true, + open: expect.any(Function), + remove: expect.any(Function), + toggle: expect.any(Function), + toggleSingle: expect.any(Function), + }); + expect(dm.openDialogCount).toBe(1); + }); + + it('opens existing dialog', () => { + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + dm.open({ id: dialogId }); + expect(dm.dialogs[dialogId].isOpen).toBeTruthy(); + expect(dm.openDialogCount).toBe(1); + }); + + it('does not open already open dialog', () => { + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + dm.open({ id: dialogId }); + dm.open({ id: dialogId }); + expect(dm.openDialogCount).toBe(1); + }); + + it('closes all other dialogs before opening the target', () => { + const dm = new DialogsManager(); + dm.open({ id: 'xxx' }); + dm.open({ id: 'yyy' }); + expect(dm.openDialogCount).toBe(2); + dm.open({ id: dialogId }, true); + expect(dm.dialogs.xxx.isOpen).toBeFalsy(); + expect(dm.dialogs.yyy.isOpen).toBeFalsy(); + expect(dm.dialogs[dialogId].isOpen).toBeTruthy(); + expect(dm.openDialogCount).toBe(1); + }); + + it('closes opened dialog', () => { + const dm = new DialogsManager(); + dm.open({ id: dialogId }); + dm.close(dialogId); + expect(dm.dialogs[dialogId].isOpen).toBeFalsy(); + expect(dm.openDialogCount).toBe(0); + }); + + it('does not close non-existent dialog', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + dm.open({ id: 'xxx' }); + dm.on('close', { id: dialogId, listener }); + dm.close(dialogId); + expect(listener).not.toHaveBeenCalled(); + expect(dm.openDialogCount).toBe(1); + }); + + it('does not close already closed dialog', () => { + const listener = jest.fn(); + const dm = new DialogsManager(); + dm.open({ id: 'xxx' }); + dm.open({ id: dialogId }); + dm.on('close', { id: dialogId, listener }); + dm.close(dialogId); + dm.close(dialogId); + expect(listener).toHaveBeenCalledTimes(1); + expect(dm.openDialogCount).toBe(1); + }); + + it('toggles the open state of a dialog', () => { + const openListener = jest.fn(); + const closeListener = jest.fn(); + const dm = new DialogsManager(); + dm.on('open', { id: dialogId, listener: openListener }); + dm.on('close', { id: dialogId, listener: closeListener }); + + dm.open({ id: 'xxx' }); + dm.open({ id: 'yyy' }); + dm.toggleOpen({ id: dialogId }); + expect(openListener).toHaveBeenCalledTimes(1); + expect(closeListener).toHaveBeenCalledTimes(0); + expect(dm.openDialogCount).toBe(3); + + dm.toggleOpen({ id: dialogId }); + expect(openListener).toHaveBeenCalledTimes(1); + expect(closeListener).toHaveBeenCalledTimes(1); + expect(dm.openDialogCount).toBe(2); + }); + + it('keeps single opened dialog when the toggling open dialog state', () => { + const openListener = jest.fn(); + const closeListener = jest.fn(); + const dm = new DialogsManager(); + dm.on('open', { id: dialogId, listener: openListener }); + dm.on('close', { id: dialogId, listener: closeListener }); + + dm.open({ id: 'xxx' }); + dm.open({ id: 'yyy' }); + dm.toggleOpenSingle({ id: dialogId }); + expect(openListener).toHaveBeenCalledTimes(1); + expect(closeListener).toHaveBeenCalledTimes(0); + expect(dm.openDialogCount).toBe(1); + + dm.toggleOpenSingle({ id: dialogId }); + expect(openListener).toHaveBeenCalledTimes(1); + expect(closeListener).toHaveBeenCalledTimes(1); + expect(dm.openDialogCount).toBe(0); + }); + + it('removes a dialog if no associated dialog event listeners', () => { + const openListener = jest.fn(); + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + dm.on('open', { id: dialogId, listener: openListener }); + dm.open({ id: dialogId }); + dm.off('open', { id: dialogId, listener: openListener }); + dm.remove(dialogId); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0); + expect(dm.openDialogCount).toBe(0); + expect(Object.keys(dm.dialogs)).toHaveLength(0); + }); + + it('does not remove a dialog if associated dialog event listeners', () => { + const openListener = jest.fn(); + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + dm.on('open', { id: dialogId, listener: openListener }); + dm.open({ id: dialogId }); + dm.remove(dialogId); + expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1); + expect(dm.openDialogCount).toBe(1); + expect(Object.keys(dm.dialogs)).toHaveLength(1); + }); + + it('handles attempt to remove non-existent dialog', () => { + const openListener = jest.fn(); + const dm = new DialogsManager(); + dm.getOrCreate({ id: dialogId }); + dm.on('open', { id: dialogId, listener: openListener }); + dm.open({ id: dialogId }); + dm.remove('xxx'); + expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1); + expect(dm.openDialogCount).toBe(1); + expect(Object.keys(dm.dialogs)).toHaveLength(1); + }); +}); diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index 802fe57610..b98178346c 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useDialogsManager } from '../../../context/DialogsManagerContext'; import type { GetOrCreateParams } from '../DialogsManager'; -export const useDialog = ({ id, isOpen }: GetOrCreateParams) => { +export const useDialog = ({ id }: GetOrCreateParams) => { const { dialogsManager } = useDialogsManager(); useEffect( @@ -12,7 +12,7 @@ export const useDialog = ({ id, isOpen }: GetOrCreateParams) => { [dialogsManager, id], ); - return dialogsManager.getOrCreate({ id, isOpen }); + return dialogsManager.getOrCreate({ id }); }; export const useDialogIsOpen = (id: string, source?: string) => {