Skip to content

Commit 20f7edb

Browse files
committed
feat(webapp): implement automatic force-reload timeout
1 parent 75a982f commit 20f7edb

File tree

2 files changed

+156
-27
lines changed

2 files changed

+156
-27
lines changed

apps/webapp/src/script/page/components/ForceReloadModal/ForceReloadModal.test.tsx

Lines changed: 117 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,24 @@
1717
*
1818
*/
1919

20+
import assert from 'node:assert';
2021
import {ReactElement} from 'react';
2122

2223
import {render} from '@testing-library/react';
2324

2425
import {usePrimaryModalState} from 'Components/Modals/PrimaryModal';
25-
import {createFakeWallClock} from 'src/script/clock/fakeWallClock';
26+
import {createFakeWallClock, FakeWallClock} from 'src/script/clock/fakeWallClock';
2627
import {MainViewModel} from 'src/script/view_model/MainViewModel';
2728
import {t} from 'Util/LocalizerUtil';
29+
import {TIME_IN_MILLIS} from 'Util/TimeUtil';
2830

2931
import {RootProvider} from '../../RootProvider';
3032
import {ForceReloadModal} from './ForceReloadModal';
3133

3234
interface ForceReloadModalTestContextValue {
3335
readonly doesApplicationNeedForceReload: boolean;
3436
readonly reloadApplication: () => void;
37+
readonly wallClock?: FakeWallClock;
3538
}
3639

3740
function isFeatureFlagDisabledForTest(): boolean {
@@ -65,22 +68,28 @@ function resetPrimaryModalState(): void {
6568
function createForceReloadModalTestElement(
6669
contextValue: ForceReloadModalTestContextValue,
6770
): ReactElement {
68-
const {doesApplicationNeedForceReload, reloadApplication} = contextValue;
71+
const {
72+
doesApplicationNeedForceReload,
73+
reloadApplication,
74+
wallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 1_111}),
75+
} = contextValue;
6976

7077
return (
7178
<RootProvider
7279
value={{
7380
doesApplicationNeedForceReload,
7481
isFeatureFlagEnabled: isFeatureFlagDisabledForTest,
7582
mainViewModel: createMainViewModelForTest(),
76-
wallClock: createFakeWallClock({initialCurrentTimestampInMilliseconds: 1_111}),
83+
wallClock,
7784
}}
7885
>
7986
<ForceReloadModal reloadApplication={reloadApplication} />
8087
</RootProvider>
8188
);
8289
}
8390

91+
const forceReloadDelayInMilliseconds = TIME_IN_MILLIS.SECOND * 60;
92+
8493
describe('ForceReloadModal', () => {
8594
beforeEach(() => {
8695
resetPrimaryModalState();
@@ -94,32 +103,53 @@ describe('ForceReloadModal', () => {
94103
});
95104

96105
it('opens the modal when force reload becomes required', () => {
106+
const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 0});
97107
const reloadApplication = jest.fn();
98-
const {rerender} = render(createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication}));
108+
const {rerender} = render(
109+
createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication, wallClock: fakeWallClock}),
110+
);
99111

100-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
112+
rerender(
113+
createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication, wallClock: fakeWallClock}),
114+
);
101115

102116
expect(usePrimaryModalState.getState().currentModalId).not.toBeNull();
103117
expect(usePrimaryModalState.getState().queue).toHaveLength(0);
104118
});
105119

106120
it('does not open the modal repeatedly while force reload remains required', () => {
121+
const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 0});
107122
const reloadApplication = jest.fn();
108-
const {rerender} = render(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
123+
const {rerender} = render(
124+
createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication, wallClock: fakeWallClock}),
125+
);
109126

110-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
111-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
127+
rerender(
128+
createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication, wallClock: fakeWallClock}),
129+
);
130+
rerender(
131+
createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication, wallClock: fakeWallClock}),
132+
);
112133

113134
expect(usePrimaryModalState.getState().queue).toHaveLength(0);
114135
});
115136

116137
it('opens the modal again after force reload status returns to false and then true', () => {
138+
const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 0});
117139
const reloadApplication = jest.fn();
118-
const {rerender} = render(createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication}));
119-
120-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
121-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication}));
122-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
140+
const {rerender} = render(
141+
createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication, wallClock: fakeWallClock}),
142+
);
143+
144+
rerender(
145+
createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication, wallClock: fakeWallClock}),
146+
);
147+
rerender(
148+
createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication, wallClock: fakeWallClock}),
149+
);
150+
rerender(
151+
createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication, wallClock: fakeWallClock}),
152+
);
123153

124154
expect(usePrimaryModalState.getState().currentModalId).not.toBeNull();
125155
expect(usePrimaryModalState.getState().queue).toHaveLength(1);
@@ -141,11 +171,82 @@ describe('ForceReloadModal', () => {
141171
currentModalContent.onBgClick();
142172
expect(usePrimaryModalState.getState().currentModalId).toBe(currentModalIdentifierBeforeBackgroundClick);
143173

144-
if (!currentModalContent.primaryAction?.action) {
145-
throw new Error('Primary reload action is missing');
146-
}
174+
assert(currentModalContent.primaryAction?.action);
175+
currentModalContent.primaryAction.action();
176+
177+
expect(reloadApplication).toHaveBeenCalledTimes(1);
178+
});
179+
180+
it('reloads automatically after 60 seconds when no user action is performed', () => {
181+
const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 0});
182+
const reloadApplication = jest.fn();
183+
184+
render(
185+
createForceReloadModalTestElement({
186+
doesApplicationNeedForceReload: true,
187+
reloadApplication,
188+
wallClock: fakeWallClock,
189+
}),
190+
);
191+
192+
fakeWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds - 1);
193+
expect(reloadApplication).not.toHaveBeenCalled();
194+
195+
fakeWallClock.advanceByMilliseconds(1);
196+
expect(reloadApplication).toHaveBeenCalledTimes(1);
197+
198+
fakeWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds);
199+
expect(reloadApplication).toHaveBeenCalledTimes(1);
200+
});
201+
202+
it('cancels automatic reload if force reload requirement is removed before timeout', () => {
203+
const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 0});
204+
const reloadApplication = jest.fn();
205+
const {rerender} = render(
206+
createForceReloadModalTestElement({
207+
doesApplicationNeedForceReload: false,
208+
reloadApplication,
209+
wallClock: fakeWallClock,
210+
}),
211+
);
212+
213+
rerender(
214+
createForceReloadModalTestElement({
215+
doesApplicationNeedForceReload: true,
216+
reloadApplication,
217+
wallClock: fakeWallClock,
218+
}),
219+
);
220+
rerender(
221+
createForceReloadModalTestElement({
222+
doesApplicationNeedForceReload: false,
223+
reloadApplication,
224+
wallClock: fakeWallClock,
225+
}),
226+
);
227+
228+
fakeWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds);
229+
230+
expect(reloadApplication).not.toHaveBeenCalled();
231+
});
232+
233+
it('does not trigger reload twice if the user clicks reload before timeout', () => {
234+
const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 0});
235+
const reloadApplication = jest.fn();
236+
237+
render(
238+
createForceReloadModalTestElement({
239+
doesApplicationNeedForceReload: true,
240+
reloadApplication,
241+
wallClock: fakeWallClock,
242+
}),
243+
);
244+
245+
const {currentModalContent} = usePrimaryModalState.getState();
147246

247+
assert(currentModalContent.primaryAction?.action);
148248
currentModalContent.primaryAction.action();
249+
fakeWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds);
149250

150251
expect(reloadApplication).toHaveBeenCalledTimes(1);
151252
});

apps/webapp/src/script/page/components/ForceReloadModal/ForceReloadModal.tsx

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,48 +17,76 @@
1717
*
1818
*/
1919

20-
import {FunctionComponent, useEffect, useRef} from 'react';
20+
import {FunctionComponent, useEffect} from 'react';
21+
22+
import {Maybe} from 'true-myth';
2123

2224
import {PrimaryModal} from 'Components/Modals/PrimaryModal';
2325
import {t} from 'Util/LocalizerUtil';
26+
import {TIME_IN_MILLIS} from 'Util/TimeUtil';
2427

2528
import {useApplicationContext} from '../../RootProvider';
2629

2730
interface ForceReloadModalProperties {
2831
readonly reloadApplication: () => void;
2932
}
3033

34+
const forceReloadDelayInMilliseconds = TIME_IN_MILLIS.SECOND * 60;
35+
3136
export const ForceReloadModal: FunctionComponent<ForceReloadModalProperties> = properties => {
3237
const {reloadApplication} = properties;
33-
const hasForceReloadModalBeenShown = useRef(false);
34-
const {doesApplicationNeedForceReload} = useApplicationContext();
38+
const {doesApplicationNeedForceReload, wallClock} = useApplicationContext();
3539

36-
useEffect(() => {
40+
useEffect((): void | (() => void) => {
3741
if (!doesApplicationNeedForceReload) {
38-
hasForceReloadModalBeenShown.current = false;
39-
return;
42+
return undefined;
4043
}
4144

42-
if (hasForceReloadModalBeenShown.current) {
43-
return;
45+
let hasApplicationReloadBeenTriggered = false;
46+
let forceReloadTimeoutIdentifier: Maybe<ReturnType<typeof globalThis.setTimeout>> = Maybe.nothing();
47+
48+
function clearScheduledForceReloadTimeout(): void {
49+
const timeoutIdentifier = forceReloadTimeoutIdentifier.unwrapOr(undefined);
50+
51+
if (timeoutIdentifier !== undefined) {
52+
wallClock.clearTimeout(timeoutIdentifier);
53+
}
54+
55+
forceReloadTimeoutIdentifier = Maybe.nothing();
4456
}
4557

46-
hasForceReloadModalBeenShown.current = true;
58+
function triggerReloadApplicationOnce(): void {
59+
if (hasApplicationReloadBeenTriggered) {
60+
return;
61+
}
62+
63+
hasApplicationReloadBeenTriggered = true;
64+
clearScheduledForceReloadTimeout();
65+
66+
reloadApplication();
67+
}
4768

4869
PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, {
4970
hideCloseBtn: true,
5071
hideSecondary: true,
5172
preventClose: true,
5273
primaryAction: {
53-
action: reloadApplication,
74+
action: triggerReloadApplicationOnce,
5475
text: t('forceReloadModalAction'),
5576
},
5677
text: {
5778
title: t('forceReloadModalTitle'),
5879
htmlMessage: t('forceReloadModalMessage'),
5980
},
6081
});
61-
}, [doesApplicationNeedForceReload, reloadApplication]);
82+
forceReloadTimeoutIdentifier = Maybe.just(
83+
wallClock.setTimeout(triggerReloadApplicationOnce, forceReloadDelayInMilliseconds),
84+
);
85+
86+
return () => {
87+
clearScheduledForceReloadTimeout();
88+
};
89+
}, [doesApplicationNeedForceReload, reloadApplication, wallClock]);
6290

6391
return null;
6492
};

0 commit comments

Comments
 (0)