From 67f5eaf4fe46ade2eb3a91a08417207623d1e9f2 Mon Sep 17 00:00:00 2001 From: Nick Watts <1156625+nawatts@users.noreply.github.com> Date: Tue, 30 Jan 2024 16:32:23 -0500 Subject: [PATCH] [UIE-151] Show countdown to refresh for required update (#4559) --- src/alerts/RequiredUpdateAlert.test.ts | 39 ++++++++ src/alerts/RequiredUpdateAlert.ts | 51 +++++++++++ src/alerts/version-alerts.test.ts | 121 ++++++++++++++++++++++++- src/alerts/version-alerts.ts | 45 ++++++++- src/alerts/version-polling.test.ts | 57 +++++++++--- src/alerts/version-polling.ts | 8 +- src/components/TopBar.js | 2 + 7 files changed, 301 insertions(+), 22 deletions(-) create mode 100644 src/alerts/RequiredUpdateAlert.test.ts create mode 100644 src/alerts/RequiredUpdateAlert.ts diff --git a/src/alerts/RequiredUpdateAlert.test.ts b/src/alerts/RequiredUpdateAlert.test.ts new file mode 100644 index 0000000000..3354d1d335 --- /dev/null +++ b/src/alerts/RequiredUpdateAlert.test.ts @@ -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('./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.' + ); + }); +}); diff --git a/src/alerts/RequiredUpdateAlert.ts b/src/alerts/RequiredUpdateAlert.ts new file mode 100644 index 0000000000..4c7786477b --- /dev/null +++ b/src/alerts/RequiredUpdateAlert.ts @@ -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', + '.', + ] + ); +}; diff --git a/src/alerts/version-alerts.test.ts b/src/alerts/version-alerts.test.ts index 5eede0a055..16ba0d7a27 100644 --- a/src/alerts/version-alerts.test.ts +++ b/src/alerts/version-alerts.test.ts @@ -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'); @@ -16,7 +16,7 @@ describe('useVersionAlerts', () => { versionStore.set({ currentVersion: 'abcd123', latestVersion: 'abcd123', - isUpdateRequired: false, + updateRequiredBy: undefined, }); // Act @@ -31,7 +31,7 @@ describe('useVersionAlerts', () => { versionStore.set({ currentVersion: 'abcd123', latestVersion: '1234567', - isUpdateRequired: false, + updateRequiredBy: undefined, }); // Act @@ -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(); + }) + ); +}); diff --git a/src/alerts/version-alerts.ts b/src/alerts/version-alerts.ts index 8e4704aa17..88017ec1e8 100644 --- a/src/alerts/version-alerts.ts +++ b/src/alerts/version-alerts.ts @@ -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'; @@ -13,13 +14,13 @@ export const getLatestVersion = async (): Promise => { export interface VersionState { currentVersion: string; latestVersion: string; - isUpdateRequired: boolean; + updateRequiredBy?: number; } export const versionStore = atom({ currentVersion: getConfig().gitRevision, latestVersion: getConfig().gitRevision, - isUpdateRequired: false, + updateRequiredBy: undefined, }); export const useVersionAlerts = (): Alert[] => { @@ -54,3 +55,43 @@ export const getBadVersions = async (): Promise => { 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(); + 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; +}; diff --git a/src/alerts/version-polling.test.ts b/src/alerts/version-polling.test.ts index 0cc013c84f..0f04694dc9 100644 --- a/src/alerts/version-polling.test.ts +++ b/src/alerts/version-polling.test.ts @@ -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( @@ -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 @@ -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'); }); @@ -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); + }) + ); }); }); @@ -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 diff --git a/src/alerts/version-polling.ts b/src/alerts/version-polling.ts index ba85b117bf..4b1638127a 100644 --- a/src/alerts/version-polling.ts +++ b/src/alerts/version-polling.ts @@ -4,6 +4,8 @@ import { getBadVersions, getLatestVersion, versionStore } from './version-alerts export const VERSION_POLLING_INTERVAL = 15 * 60 * 1000; // 15 minutes +export const FORCED_UPDATE_DELAY = 10 * 60 * 1000; // 10 minutes + export const checkVersion = withErrorIgnoring(async (): Promise => { const { currentVersion } = versionStore.get(); @@ -13,7 +15,11 @@ export const checkVersion = withErrorIgnoring(async (): Promise => { if (latestVersion !== currentVersion) { const badVersions = await getBadVersions(); if (badVersions.includes(currentVersion)) { - versionStore.update((value) => ({ ...value, isUpdateRequired: true })); + versionStore.update((value) => ({ + ...value, + updateRequiredBy: + value.updateRequiredBy === undefined ? Date.now() + FORCED_UPDATE_DELAY : value.updateRequiredBy, + })); } } }); diff --git a/src/components/TopBar.js b/src/components/TopBar.js index 7440b6e064..9f9e373cef 100644 --- a/src/components/TopBar.js +++ b/src/components/TopBar.js @@ -5,6 +5,7 @@ import { UnmountClosed as RCollapse } from 'react-collapse'; import { a, div, h, h1, img, span } from 'react-hyperscript-helpers'; import { Transition } from 'react-transition-group'; import { AlertsIndicator } from 'src/alerts/Alerts'; +import { RequiredUpdateAlert } from 'src/alerts/RequiredUpdateAlert'; import { signIn, signOut } from 'src/auth/auth'; import { Clickable, IdContainer, LabeledCheckbox, Link, spinnerOverlay } from 'src/components/common'; import { icon } from 'src/components/icons'; @@ -625,6 +626,7 @@ const TopBar = ({ showMenu = true, title, href, children }) => { ), ] ), + h(RequiredUpdateAlert), h(SkipNavTarget, { ref: mainRef }), ] );