diff --git a/frontend/packages/shared/src/hooks/useEventListener.test.ts b/frontend/packages/shared/src/hooks/useEventListener.test.ts new file mode 100644 index 00000000000..67ee38f671c --- /dev/null +++ b/frontend/packages/shared/src/hooks/useEventListener.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react-dom/test-utils'; + +import { useEventListener } from './useEventListener'; + +const user = userEvent.setup(); + +const renderUseEventListener = (eventType: string, action: () => void) => + renderHook(() => useEventListener(eventType, action), { + initialProps: { eventType, action }, + }); + +describe('useEventListener', () => { + it('Calls action when given event happens', async () => { + const action = jest.fn(); + renderUseEventListener('click', action); + await act(() => user.click(document.body)); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('Does not call action when another event is given', async () => { + const action = jest.fn(); + renderUseEventListener('click', action); + await act(() => user.keyboard('{Enter}')); + expect(action).not.toHaveBeenCalled(); + }); + + it('Removes event listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn( + window, + 'removeEventListener', + ); + const { unmount } = renderUseEventListener('click', jest.fn()); + expect(removeEventListenerSpy).not.toHaveBeenCalled(); + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/frontend/packages/shared/src/hooks/useEventListener.ts b/frontend/packages/shared/src/hooks/useEventListener.ts new file mode 100644 index 00000000000..c33e8b74460 --- /dev/null +++ b/frontend/packages/shared/src/hooks/useEventListener.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; + +/** + * Adds an event listener to the given element or the document body if no element is given. The listener is removed on unmount. + * @param eventType The event type to listen for. + * @param action The action to perform when the event is triggered. + */ +export function useEventListener( + eventType: string, + action: () => void, +) { + useEffect(() => { + window.addEventListener(eventType, action); + return () => window.removeEventListener(eventType, action); + }, [eventType, action]); +} diff --git a/frontend/packages/shared/src/hooks/useLocalStorage.ts b/frontend/packages/shared/src/hooks/useLocalStorage.ts index 1923b941a8b..e85c06cf242 100644 --- a/frontend/packages/shared/src/hooks/useLocalStorage.ts +++ b/frontend/packages/shared/src/hooks/useLocalStorage.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { TypedStorage, typedLocalStorage } from 'app-shared/utils/webStorage'; const useWebStorage = ( @@ -8,20 +8,6 @@ const useWebStorage = ( ): [T, (newValue: T) => void, () => void] => { const [value, setValue] = useState(typedStorage.getItem(key) || initialValue); - const handleStorageChange = useCallback(() => { - const item = typedStorage.getItem(key); - setValue(item); - }, [key, setValue, typedStorage]); - - useEffect(() => { - - window.addEventListener('storage', handleStorageChange); - - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, [handleStorageChange]); - const setStorageValue = useCallback( (newValue: T) => { typedStorage.setItem(key, newValue); diff --git a/frontend/packages/shared/src/hooks/useReactiveLocalStorage.ts b/frontend/packages/shared/src/hooks/useReactiveLocalStorage.ts new file mode 100644 index 00000000000..30d1afcced0 --- /dev/null +++ b/frontend/packages/shared/src/hooks/useReactiveLocalStorage.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; +import { typedLocalStorage } from 'app-shared/utils/webStorage'; +import { useLocalStorage } from './useLocalStorage'; +import { useEventListener } from './useEventListener'; + +export const useReactiveLocalStorage = ( + key: string, + initialValue?: T, +): [T, (newValue: T) => void, () => void] => { + const [value, setValue, removeValue] = useLocalStorage(key, initialValue); + + const handleStorageChange = useCallback(() => { + const item = typedLocalStorage.getItem(key); + setValue(item); + }, [key, setValue]); + + useEventListener('storage', handleStorageChange); + + return [value, setValue, removeValue]; +}; diff --git a/frontend/packages/ux-editor/src/SubApp.tsx b/frontend/packages/ux-editor/src/SubApp.tsx index 5f5334695f3..690645b143d 100644 --- a/frontend/packages/ux-editor/src/SubApp.tsx +++ b/frontend/packages/ux-editor/src/SubApp.tsx @@ -4,13 +4,13 @@ import { App } from './App'; import { store } from './store'; import './styles/index.css'; import { AppContext } from './AppContext'; -import { useLocalStorage } from 'app-shared/hooks/useLocalStorage'; +import { useReactiveLocalStorage } from 'app-shared/hooks/useReactiveLocalStorage'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; export const SubApp = () => { const previewIframeRef = useRef(null); const { app } = useStudioUrlParams(); - const [selectedLayoutSet, setSelectedLayoutSet, removeSelectedLayoutSet] = useLocalStorage('layoutSet/' + app, null); + const [selectedLayoutSet, setSelectedLayoutSet, removeSelectedLayoutSet] = useReactiveLocalStorage('layoutSet/' + app, null); return (