Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a useTiming hook #4057

Merged
merged 1 commit into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}

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;
Loading