diff --git a/src/web/hooks/__tests__/useTiming.jsx b/src/web/hooks/__tests__/useTiming.jsx new file mode 100644 index 0000000000..28014a8440 --- /dev/null +++ b/src/web/hooks/__tests__/useTiming.jsx @@ -0,0 +1,141 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable react/prop-types */ + +import {useState} from 'react'; + +import {describe, test, expect, testing} from '@gsa/testing'; + +import {act, fireEvent, render, screen} from 'web/utils/testing'; + +import useTiming from '../useTiming'; + +const TestComponent = ({doFunc}) => { + const [value, setValue] = useState(0); + const timingFunc = () => { + setValue(value => value + 1); + return doFunc(); + }; + const [startTimer, clearTimer, isRunning] = useTiming(timingFunc, 900); + return ( + <> + + + {value} + {'' + isRunning} + + ); +}; + +const runTimers = async () => { + await act(async () => { + await testing.advanceTimersToNextTimerAsync(); + }); +}; + +describe('useTiming', () => { + test('should start a timer', async () => { + testing.useFakeTimers(); + const doFunc = testing.fn().mockImplementation(() => {}); + + render(); + + const value = screen.getByTestId('value'); + const isRunning = screen.getByTestId('isRunning'); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('false'); + + fireEvent.click(screen.getByTestId('startTimer')); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('true'); + + await runTimers(); + + expect(screen.getByTestId('value')).toHaveTextContent('1'); + expect(screen.getByTestId('isRunning')).toHaveTextContent('false'); + }); + + test('should keep running a timer when a promise is used', async () => { + testing.useFakeTimers(); + const doFunc = testing.fn().mockResolvedValueOnce(); + + render(); + + const value = screen.getByTestId('value'); + const isRunning = screen.getByTestId('isRunning'); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('false'); + + fireEvent.click(screen.getByTestId('startTimer')); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('true'); + + await runTimers(); + + expect(screen.getByTestId('value')).toHaveTextContent('1'); + expect(screen.getByTestId('isRunning')).toHaveTextContent('true'); + + await runTimers(); + + expect(screen.getByTestId('value')).toHaveTextContent('2'); + expect(screen.getByTestId('isRunning')).toHaveTextContent('false'); + }); + + test('should not rerun timer when a promise fails', async () => { + testing.useFakeTimers(); + const doFunc = testing.fn().mockRejectedValue(); + + render(); + + const value = screen.getByTestId('value'); + const isRunning = screen.getByTestId('isRunning'); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('false'); + + fireEvent.click(screen.getByTestId('startTimer')); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('true'); + + await runTimers(); + + expect(screen.getByTestId('value')).toHaveTextContent('1'); + expect(screen.getByTestId('isRunning')).toHaveTextContent('false'); + }); + + test('should allow to clear the timer', async () => { + testing.useFakeTimers(); + const doFunc = testing.fn().mockResolvedValue(); + + render(); + + const value = screen.getByTestId('value'); + const isRunning = screen.getByTestId('isRunning'); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('false'); + + fireEvent.click(screen.getByTestId('startTimer')); + + expect(value).toHaveTextContent('0'); + expect(isRunning).toHaveTextContent('true'); + + await runTimers(); + + expect(screen.getByTestId('value')).toHaveTextContent('1'); + expect(screen.getByTestId('isRunning')).toHaveTextContent('true'); + + fireEvent.click(screen.getByTestId('clearTimer')); + + expect(screen.getByTestId('value')).toHaveTextContent('1'); + expect(screen.getByTestId('isRunning')).toHaveTextContent('false'); + }); +}); diff --git a/src/web/hooks/useTiming.js b/src/web/hooks/useTiming.js new file mode 100644 index 0000000000..07ce4acad4 --- /dev/null +++ b/src/web/hooks/useTiming.js @@ -0,0 +1,107 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useEffect, useCallback, useState} from 'react'; + +import logger from 'gmp/log'; + +import {hasValue, isFunction} from 'gmp/utils/identity'; + +import useInstanceVariable from './useInstanceVariable'; + +const log = logger.getLogger('web.hooks.useTiming'); + +/** + * Hook to start a timer that calls a function after a given timeout + * + * @param {Function} doFunc The function to call + * @param {Number|Function} timeout The timeout in milliseconds or a function that returns the timeout + * @returns Array of startTimer function, clearTimer function and boolean isRunning + */ +const useTiming = (doFunc, timeout) => { + const timer = useInstanceVariable({}); + const [timerId, setTimerId] = useState(); // store timerId in state too to trigger re-render if it changes + const isRunning = Boolean(timerId); + timer.doFunc = doFunc; // always use the latest version of the function + + const updateTimerId = useCallback( + newTimerId => { + timer.timerId = newTimerId; + setTimerId(newTimerId); + }, + [timer], + ); + + const startTimer = useCallback(() => { + if (hasValue(timer.timerId)) { + log.debug('Not starting timer. A timer is already running.', { + timer: timer.timerId, + }); + return; + } + + const timeoutValue = isFunction(timeout) ? timeout() : timeout; + + if (!hasValue(timeoutValue) || timeoutValue < 0) { + log.debug('Not starting timer because timeout value was', timeoutValue); + return; + } + + updateTimerId( + setTimeout(async () => { + log.debug('Timer with id', timer.timerId, 'fired.'); + + const promise = timer.doFunc(); + try { + if (promise?.then) { + await promise; + updateTimerId(); + timer.startTimer(); + } else { + updateTimerId(); + } + } catch (error) { + updateTimerId(); + } + }, timeoutValue), + ); + + log.debug( + 'Started timer with id', + timer.timerId, + 'and timeout of', + timeoutValue, + 'milliseconds', + ); + }, [timeout, timer, updateTimerId]); + + const clearTimer = useCallback(() => { + if (hasValue(timer.timerId)) { + log.debug('Clearing timer with id', timer.timerId); + + clearTimeout(timer.timerId); + updateTimerId(); + } + }, [timer, updateTimerId]); + + useEffect(() => { + // put starTimer func into timer ref to allow referencing the NEWEST version + // when a promise has ended + timer.startTimer = startTimer; + }); + + // clear timer on unmount + useEffect( + () => () => { + log.debug('Removing timer on unmount'); + clearTimer(); + }, + [clearTimer], + ); + + return [startTimer, clearTimer, isRunning]; +}; + +export default useTiming;