Skip to content

Commit

Permalink
Add: Implement a useTiming hook
Browse files Browse the repository at this point in the history
The hook can be used to run a function after a specific amount of time
for example for doing a reload of data.
  • Loading branch information
bjoernricks committed Jun 12, 2024
1 parent d3e9a3e commit f465ab3
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 0 deletions.
141 changes: 141 additions & 0 deletions src/web/hooks/__tests__/useTiming.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button onClick={startTimer} data-testid="startTimer"></button>
<button onClick={clearTimer} data-testid="clearTimer"></button>
<span data-testid="value">{value}</span>
<span data-testid="isRunning">{'' + isRunning}</span>
</>
);
};

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(<TestComponent doFunc={doFunc} />);

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(<TestComponent doFunc={doFunc} />);

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(<TestComponent doFunc={doFunc} />);

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(<TestComponent doFunc={doFunc} />);

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');
});
});
107 changes: 107 additions & 0 deletions src/web/hooks/useTiming.js
Original file line number Diff line number Diff line change
@@ -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;
}

Check warning on line 43 in src/web/hooks/useTiming.js

View check run for this annotation

Codecov / codecov/patch

src/web/hooks/useTiming.js#L39-L43

Added lines #L39 - L43 were not covered by tests

const timeoutValue = isFunction(timeout) ? timeout() : timeout;

if (!hasValue(timeoutValue) || timeoutValue < 0) {
log.debug('Not starting timer because timeout value was', timeoutValue);
return;
}

Check warning on line 50 in src/web/hooks/useTiming.js

View check run for this annotation

Codecov / codecov/patch

src/web/hooks/useTiming.js#L48-L50

Added lines #L48 - L50 were not covered by tests

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;

0 comments on commit f465ab3

Please sign in to comment.