Skip to content

Commit

Permalink
[UIE-151] Show countdown to refresh for required update (#4559)
Browse files Browse the repository at this point in the history
  • Loading branch information
nawatts authored Jan 30, 2024
1 parent e1e906e commit 67f5eaf
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 22 deletions.
39 changes: 39 additions & 0 deletions src/alerts/RequiredUpdateAlert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { h } from 'react-hyperscript-helpers';
import { asMockedFn, renderWithAppContexts } from 'src/testing/test-utils';

import { RequiredUpdateAlert } from './RequiredUpdateAlert';
import { useTimeUntilRequiredUpdate } from './version-alerts';

type VersionAlertsExports = typeof import('./version-alerts');
jest.mock('./version-alerts', (): VersionAlertsExports => {
return {
...jest.requireActual<VersionAlertsExports>('./version-alerts'),
useTimeUntilRequiredUpdate: jest.fn(),
};
});

describe('RequiredUpdateAlert', () => {
it('renders nothing if no update is required', () => {
// Arrange
asMockedFn(useTimeUntilRequiredUpdate).mockReturnValue(undefined);

// Act
const { container } = renderWithAppContexts(h(RequiredUpdateAlert));

// Assert
expect(container).toBeEmptyDOMElement();
});

it('renders time until required update', () => {
// Arrange
asMockedFn(useTimeUntilRequiredUpdate).mockReturnValue(90);

// Act
const { container } = renderWithAppContexts(h(RequiredUpdateAlert));

// Assert
expect(container).toHaveTextContent(
'A required update is available. Terra will automatically refresh your browser in 1 minute 30 seconds.'
);
});
});
51 changes: 51 additions & 0 deletions src/alerts/RequiredUpdateAlert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { icon, useThemeFromContext } from '@terra-ui-packages/components';
import { formatDuration } from 'date-fns';
import { Fragment, ReactNode } from 'react';
import { div, h, span } from 'react-hyperscript-helpers';

import { useTimeUntilRequiredUpdate } from './version-alerts';

export const RequiredUpdateAlert = (): ReactNode => {
const { colors } = useThemeFromContext();

const timeUntilRequiredUpdate = useTimeUntilRequiredUpdate();

if (timeUntilRequiredUpdate === undefined) {
return null;
}

const minutesRemaining = Math.floor(timeUntilRequiredUpdate / 60);
const secondsRemaining = timeUntilRequiredUpdate % 60;

return div(
{
role: 'alert',
style: {
display: 'flex',
alignItems: 'center',
padding: '1rem 1.25rem',
border: `2px solid ${colors.warning()}`,
backgroundColor: colors.warning(0.15),
color: colors.dark(),
fontWeight: 'bold',
fontSize: 12,
},
},
[
icon('warning-standard', { size: 26, color: colors.warning(), style: { marginRight: '1ch' } }),
'A required update is available. Terra will automatically refresh your browser ',
timeUntilRequiredUpdate > 0
? h(Fragment, [
'in ',
span({ role: 'timer', style: { marginLeft: '0.5ch' } }, [
formatDuration({
minutes: minutesRemaining,
seconds: secondsRemaining,
}),
]),
])
: 'now',
'.',
]
);
};
121 changes: 116 additions & 5 deletions src/alerts/version-alerts.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DeepPartial } from '@terra-ui-packages/core-utils';
import { asMockedFn } from '@terra-ui-packages/test-utils';
import { renderHook } from '@testing-library/react';
import { asMockedFn, withFakeTimers } from '@terra-ui-packages/test-utils';
import { act, renderHook } from '@testing-library/react';
import { Ajax } from 'src/libs/ajax';

import { getBadVersions, useVersionAlerts, versionStore } from './version-alerts';
import { getBadVersions, useTimeUntilRequiredUpdate, useVersionAlerts, versionStore } from './version-alerts';

type AjaxExports = typeof import('src/libs/ajax');
jest.mock('src/libs/ajax');
Expand All @@ -16,7 +16,7 @@ describe('useVersionAlerts', () => {
versionStore.set({
currentVersion: 'abcd123',
latestVersion: 'abcd123',
isUpdateRequired: false,
updateRequiredBy: undefined,
});

// Act
Expand All @@ -31,7 +31,7 @@ describe('useVersionAlerts', () => {
versionStore.set({
currentVersion: 'abcd123',
latestVersion: '1234567',
isUpdateRequired: false,
updateRequiredBy: undefined,
});

// Act
Expand Down Expand Up @@ -85,3 +85,114 @@ describe('getBadVersions', () => {
expect(badVersions).toEqual([]);
});
});

describe('useTimeUntilRequiredUpdate', () => {
const originalLocation = window.location;

beforeAll(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { reload: jest.fn() },
});
});

afterAll(() => {
window.location = originalLocation;
});

it('returns undefined if no update is required', () => {
// Arrange
versionStore.set({
currentVersion: 'abcd123',
latestVersion: 'abcd123',
updateRequiredBy: undefined,
});

// Act
const { result: hookReturnRef } = renderHook(() => useTimeUntilRequiredUpdate());

// Assert
expect(hookReturnRef.current).toBeUndefined();
});

it(
'rerenders when version store is updated',
withFakeTimers(() => {
// Arrange
versionStore.set({
currentVersion: 'abcd123',
latestVersion: '1234567',
updateRequiredBy: undefined,
});

const initialTime = 1706504400000;
jest.setSystemTime(initialTime);

const { result: hookReturnRef } = renderHook(() => useTimeUntilRequiredUpdate());

// Act
const updateIntervalInSeconds = 300;
act(() => {
versionStore.set({
currentVersion: 'abcd123',
latestVersion: '1234567',
updateRequiredBy: initialTime + updateIntervalInSeconds * 1000,
});
});

// Assert
expect(hookReturnRef.current).toBe(updateIntervalInSeconds);
})
);

it(
'returns time until required update',
withFakeTimers(() => {
// Arrange
const initialTime = 1706504400000;
const timeUntilUpdateInSeconds = 60;
versionStore.set({
currentVersion: 'abcd123',
latestVersion: '1234567',
updateRequiredBy: initialTime + timeUntilUpdateInSeconds * 1000,
});

jest.setSystemTime(initialTime);

// Act
const { result: hookReturnRef } = renderHook(() => useTimeUntilRequiredUpdate());

// Assert
expect(hookReturnRef.current).toBe(timeUntilUpdateInSeconds);

// Act
act(() => jest.advanceTimersByTime((timeUntilUpdateInSeconds / 2) * 1000));

// Assert
expect(hookReturnRef.current).toBe(timeUntilUpdateInSeconds / 2);
})
);

it(
'reloads browser when time until required update elapses',
withFakeTimers(() => {
// Arrange
const initialTime = 1706504400000;
const timeUntilUpdate = 60000;
versionStore.set({
currentVersion: 'abcd123',
latestVersion: '1234567',
updateRequiredBy: initialTime + timeUntilUpdate,
});

jest.setSystemTime(initialTime);

// Act
renderHook(() => useTimeUntilRequiredUpdate());
act(() => jest.advanceTimersByTime(timeUntilUpdate));

// Assert
expect(window.location.reload).toHaveBeenCalled();
})
);
});
45 changes: 43 additions & 2 deletions src/alerts/version-alerts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { atom } from '@terra-ui-packages/core-utils';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Ajax } from 'src/libs/ajax';
import { getConfig } from 'src/libs/config';
import { useStore } from 'src/libs/react-utils';
Expand All @@ -13,13 +14,13 @@ export const getLatestVersion = async (): Promise<string> => {
export interface VersionState {
currentVersion: string;
latestVersion: string;
isUpdateRequired: boolean;
updateRequiredBy?: number;
}

export const versionStore = atom<VersionState>({
currentVersion: getConfig().gitRevision,
latestVersion: getConfig().gitRevision,
isUpdateRequired: false,
updateRequiredBy: undefined,
});

export const useVersionAlerts = (): Alert[] => {
Expand Down Expand Up @@ -54,3 +55,43 @@ export const getBadVersions = async (): Promise<string[]> => {
throw error;
}
};

export const useTimeUntilRequiredUpdate = (): number | undefined => {
const { updateRequiredBy } = useStore(versionStore);

const [timeRemaining, setTimeRemaining] = useState(
updateRequiredBy ? Math.ceil((updateRequiredBy - Date.now()) / 1000) : undefined
);
const updateTimeRemaining = useCallback(() => {
if (updateRequiredBy) {
const timeRemaining = Math.ceil((updateRequiredBy - Date.now()) / 1000);
if (timeRemaining <= 0) {
window.location.reload();
}

setTimeRemaining(timeRemaining);
} else {
setTimeRemaining(undefined);
}
}, [updateRequiredBy]);

const countdownInterval = useRef<number>();
useEffect(() => {
updateTimeRemaining();
if (updateRequiredBy && !countdownInterval.current) {
countdownInterval.current = window.setInterval(updateTimeRemaining, 1000);
} else if (!updateRequiredBy && countdownInterval.current) {
clearInterval(countdownInterval.current);
}
}, [updateRequiredBy, updateTimeRemaining]);

useEffect(() => {
return () => {
if (countdownInterval.current) {
clearInterval(countdownInterval.current);
}
};
}, []);

return timeRemaining;
};
57 changes: 43 additions & 14 deletions src/alerts/version-polling.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { asMockedFn, withFakeTimers } from '@terra-ui-packages/test-utils';

import { getBadVersions, getLatestVersion, versionStore } from './version-alerts';
import { checkVersion, startPollingVersion, VERSION_POLLING_INTERVAL } from './version-polling';
import { checkVersion, FORCED_UPDATE_DELAY, startPollingVersion, VERSION_POLLING_INTERVAL } from './version-polling';

type VersionAlertsExports = typeof import('./version-alerts');
jest.mock(
Expand All @@ -16,7 +16,7 @@ jest.mock(
describe('checkVersion', () => {
it('fetches latest version and updates store', async () => {
// Arrange
versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', isUpdateRequired: false });
versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', updateRequiredBy: undefined });
asMockedFn(getLatestVersion).mockResolvedValue('abcd123');

// Act
Expand All @@ -29,7 +29,7 @@ describe('checkVersion', () => {

describe('if a new version is available', () => {
beforeEach(() => {
versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', isUpdateRequired: false });
versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', updateRequiredBy: undefined });
asMockedFn(getLatestVersion).mockResolvedValue('1234567');
});

Expand All @@ -44,16 +44,45 @@ describe('checkVersion', () => {
expect(getBadVersions).toHaveBeenCalled();
});

it('sets updated required flag if current version is bad', async () => {
// Arrange
asMockedFn(getBadVersions).mockResolvedValue(['abcd123']);

// Act
await checkVersion();

// Assert
expect(versionStore.get()).toMatchObject({ isUpdateRequired: true });
});
it(
'sets update required time if current version is bad',
withFakeTimers(async () => {
// Arrange
const initialTime = 1706504400000;
jest.setSystemTime(initialTime);
asMockedFn(getBadVersions).mockResolvedValue(['abcd123']);

// Act
await checkVersion();

// Assert
const { updateRequiredBy } = versionStore.get();
expect(updateRequiredBy).toBe(initialTime + FORCED_UPDATE_DELAY);
})
);

it(
'does not overwrite update required time on subsequent checks',
withFakeTimers(async () => {
// Arrange
const initialTime = 1706504400000;
jest.setSystemTime(initialTime);
asMockedFn(getBadVersions).mockResolvedValue(['abcd123']);

await checkVersion();

const { updateRequiredBy: updateRequiredByAfterFirstPoll } = versionStore.get();
expect(updateRequiredByAfterFirstPoll).toBe(initialTime + FORCED_UPDATE_DELAY);

// Act
jest.advanceTimersByTime(VERSION_POLLING_INTERVAL);
await checkVersion();

// Assert
const { updateRequiredBy: updateRequiredByAfterSecondPoll } = versionStore.get();
expect(updateRequiredByAfterSecondPoll).toBe(updateRequiredByAfterFirstPoll);
})
);
});
});

Expand All @@ -64,7 +93,7 @@ describe('startPollingVersion', () => {
'periodically fetches latest version and updates store',
withFakeTimers(async () => {
// Arrange
versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', isUpdateRequired: false });
versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', updateRequiredBy: undefined });
asMockedFn(getLatestVersion).mockResolvedValue('1234567');

// Act
Expand Down
Loading

0 comments on commit 67f5eaf

Please sign in to comment.