diff --git a/src/web/hooks/__tests__/useTiming.jsx b/src/web/hooks/__tests__/useTiming.jsx new file mode 100644 index 0000000000..29402ef1ee --- /dev/null +++ b/src/web/hooks/__tests__/useTiming.jsx @@ -0,0 +1,56 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +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 = () => { + const [value, setValue] = useState(0); + const updateValue = () => setValue(1); + // eslint-disable-next-line no-unused-vars + const [startTimer, clearTimer, isRunning] = useTiming(updateValue, 900); + return ( + <> + + {value} + {'' + isRunning} + + ); +}; + +const runTimers = async () => { + await act(async () => { + await testing.runAllTimersAsync(); + }); +}; + +describe('useTiming', () => { + test('should start a timer', async () => { + testing.useFakeTimers(); + + 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'); + }); +}); diff --git a/src/web/hooks/useTiming.js b/src/web/hooks/useTiming.js new file mode 100644 index 0000000000..f8006e578c --- /dev/null +++ b/src/web/hooks/useTiming.js @@ -0,0 +1,109 @@ +/* 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, isDefined} 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 = !!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(() => { + log.debug('Timer with id', timer.timerId, 'fired.'); + + const promise = timer.doFunc(); + + if (isDefined(promise?.then)) { + promise + .then(() => { + updateTimerId(undefined); + timer.startTimer(); + }) + .catch(() => { + updateTimerId(undefined); + }); + } else { + updateTimerId(undefined); + } + }, 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(undefined); + } + }, [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;