dce-reactkit 5.0.8 → 5.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ import BackButtonController from '../types/BackButtonController';
2
+ /**
3
+ * Controller for driving back navigation from anywhere in the app. Requires
4
+ * useBackButton to have been called in the top-level app
5
+ * @author Yuen Ler Chow
6
+ */
7
+ export declare const backButtonController: BackButtonController;
8
+ /**
9
+ * Hook that makes the browser's back button navigate within the app instead of
10
+ * leaving it. Call this once in your top-level app, then use
11
+ * backButtonController to enter subpanels and describe their state.
12
+ *
13
+ * Assumes a single level of navigation: one home screen plus subpanels that the
14
+ * user returns home from.
15
+ * @author Yuen Ler Chow
16
+ * @param handleGoHomeFunc handler that performs the app state changes required
17
+ * to return to the home screen
18
+ */
19
+ declare const useBackButton: (handleGoHomeFunc: () => void) => void;
20
+ export default useBackButton;
@@ -37,6 +37,7 @@ import isMobileOrTablet from './helpers/isMobileOrTablet';
37
37
  import makeLinksClickable from './helpers/makeLinksClickable';
38
38
  import combineClassNames from './helpers/combineClassNames';
39
39
  import useForceRender from './helpers/useForceRender';
40
+ import useBackButton, { backButtonController } from './helpers/useBackButton';
40
41
  import isSelectAdmin from './helpers/isSelectAdmin';
41
42
  import ModalButtonType from './types/ModalButtonType';
42
43
  import ModalSize from './types/ModalSize';
@@ -45,8 +46,10 @@ import Variant from './types/Variant';
45
46
  import IntelliTableColumn from './types/IntelliTableColumn';
46
47
  import DropdownItemType from './types/DropdownItemType';
47
48
  import ProgressBarSize from './types/ProgressBarSize';
49
+ import BackState from './types/BackState';
50
+ import BackButtonController from './types/BackButtonController';
48
51
  import PickableItem from './components/ItemPicker/types/PickableItem';
49
52
  import DBEntry from './components/DBEntryManagerPanel/types/DBEntry';
50
53
  import DBEntryField from './components/DBEntryManagerPanel/types/DBEntryField';
51
54
  import DBEntryFieldType from './components/DBEntryManagerPanel/types/DBEntryFieldType';
52
- export { AppWrapper, LoadingSpinner, ErrorBox, Modal, TabBox, RadioButton, CheckboxButton, ButtonInputGroup, SimpleDateChooser, SimpleMonthChooser, SimpleTimeChooser, Drawer, PopSuccessMark, PopFailureMark, PopPendingMark, CopiableBox, ItemPicker, LogReviewer, IntelliTable, CSVDownloadButton, DBEntryManagerPanel, Tooltip, ToggleSwitch, AutoscrollToBottomContainer, MultiSwitch, Dropdown, ProgressBar, FakeProgressBar, alert, prompt, confirm, showFatalError, DynamicWord, stubServerEndpoint, canReviewLogs, isMobileOrTablet, makeLinksClickable, isSelectAdmin, initClient, visitServerEndpoint, logClientEvent, addFatalErrorHandler, leaveToURL, combineClassNames, useForceRender, setClientEventMetadataPopulator, ModalButtonType, ModalSize, ModalType, Variant, IntelliTableColumn, DropdownItemType, LogReviewerFilterState, ProgressBarSize, PickableItem, DBEntry, DBEntryField, DBEntryFieldType, ErrorWithCode, MINUTE_IN_MS, HOUR_IN_MS, DAY_IN_MS, LOG_REVIEW_ROUTE_PATH_PREFIX, LOG_ROUTE_PATH, LOG_REVIEW_STATUS_ROUTE, LOG_REVIEW_GET_LOGS_ROUTE, SELECT_ADMIN_CHECK_ROUTE, abbreviate, avg, ceilToNumDecimals, floorToNumDecimals, forceNumIntoBounds, padDecimalZeros, padZerosLeft, roundToNumDecimals, sum, waitMs, getOrdinal, getTimeInfoInET, getMondayOfTimestamp, startMinWait, getHumanReadableDate, getPartOfDay, stringsToHumanReadableList, onlyKeepLetters, parallelLimit, getMonthName, genCSV, extractProp, compareArraysByProp, getLocalTimeInfo, genCommaList, validateEmail, validatePhoneNumber, validateString, idify, prefixWithAOrAn, everyAsync, filterAsync, forEachAsync, mapAsync, someAsync, capitalize, shuffleArray, getWordCount, cloneDeep, getTimestampFromTimeInfoInET, spaceAtCapitals, ParamType, DayOfWeek, Log, LogType, LogSource, LogAction, LogBuiltInMetadata, LogMetadataType, LogFunction, LogTypeSpecificInfo, LogMainInfo, LogSourceSpecificInfo, LogLevel, CommonKitErrorCode, };
55
+ export { AppWrapper, LoadingSpinner, ErrorBox, Modal, TabBox, RadioButton, CheckboxButton, ButtonInputGroup, SimpleDateChooser, SimpleMonthChooser, SimpleTimeChooser, Drawer, PopSuccessMark, PopFailureMark, PopPendingMark, CopiableBox, ItemPicker, LogReviewer, IntelliTable, CSVDownloadButton, DBEntryManagerPanel, Tooltip, ToggleSwitch, AutoscrollToBottomContainer, MultiSwitch, Dropdown, ProgressBar, FakeProgressBar, alert, prompt, confirm, showFatalError, DynamicWord, stubServerEndpoint, canReviewLogs, isMobileOrTablet, makeLinksClickable, isSelectAdmin, initClient, visitServerEndpoint, logClientEvent, addFatalErrorHandler, leaveToURL, combineClassNames, useForceRender, useBackButton, backButtonController, setClientEventMetadataPopulator, ModalButtonType, ModalSize, ModalType, Variant, IntelliTableColumn, DropdownItemType, LogReviewerFilterState, ProgressBarSize, BackState, BackButtonController, PickableItem, DBEntry, DBEntryField, DBEntryFieldType, ErrorWithCode, MINUTE_IN_MS, HOUR_IN_MS, DAY_IN_MS, LOG_REVIEW_ROUTE_PATH_PREFIX, LOG_ROUTE_PATH, LOG_REVIEW_STATUS_ROUTE, LOG_REVIEW_GET_LOGS_ROUTE, SELECT_ADMIN_CHECK_ROUTE, abbreviate, avg, ceilToNumDecimals, floorToNumDecimals, forceNumIntoBounds, padDecimalZeros, padZerosLeft, roundToNumDecimals, sum, waitMs, getOrdinal, getTimeInfoInET, getMondayOfTimestamp, startMinWait, getHumanReadableDate, getPartOfDay, stringsToHumanReadableList, onlyKeepLetters, parallelLimit, getMonthName, genCSV, extractProp, compareArraysByProp, getLocalTimeInfo, genCommaList, validateEmail, validatePhoneNumber, validateString, idify, prefixWithAOrAn, everyAsync, filterAsync, forEachAsync, mapAsync, someAsync, capitalize, shuffleArray, getWordCount, cloneDeep, getTimestampFromTimeInfoInET, spaceAtCapitals, ParamType, DayOfWeek, Log, LogType, LogSource, LogAction, LogBuiltInMetadata, LogMetadataType, LogFunction, LogTypeSpecificInfo, LogMainInfo, LogSourceSpecificInfo, LogLevel, CommonKitErrorCode, };
@@ -0,0 +1,40 @@
1
+ import BackState from './BackState';
2
+ /**
3
+ * Controller for the back button, used to drive back navigation from anywhere
4
+ * in the app. Pass this to subpanels so they can describe their state and
5
+ * send the user home.
6
+ * @author Yuen Ler Chow
7
+ */
8
+ type BackButtonController = {
9
+ /**
10
+ * Call this when the user navigates to a child of the home screen (something
11
+ * they can come back from)
12
+ */
13
+ onSubpanelEntered: () => void;
14
+ /**
15
+ * Send the user back to the home screen
16
+ * @param [force] if true, go home immediately without checking the subpanel
17
+ * state (no confirmation, not blocked). If falsy, nothing happens when
18
+ * blocked and confirmation is required when there are unsaved changes
19
+ */
20
+ goHome: (force?: boolean) => Promise<void>;
21
+ /**
22
+ * Set the state of the current subpanel, which determines what happens when
23
+ * the user tries to go back
24
+ * @param newSubpanelState the new state of the subpanel
25
+ */
26
+ setSubpanelState: (newSubpanelState: BackState) => void;
27
+ /**
28
+ * Set the confirmation message shown if the user tries to go back while there
29
+ * are unsaved changes (cleared upon returning to the home screen)
30
+ * @param message the message to show
31
+ */
32
+ setCustomUnsavedChangesMessage: (message: string) => void;
33
+ /**
34
+ * Set the message shown if the user tries to go back while blocked (cleared
35
+ * upon returning to the home screen)
36
+ * @param message the message to show
37
+ */
38
+ setCustomBlockedMessage: (message: string) => void;
39
+ };
40
+ export default BackButtonController;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * State of the current subpanel, determining what happens when the user tries
3
+ * to go back to the home screen
4
+ * @author Yuen Ler Chow
5
+ */
6
+ declare enum BackState {
7
+ Normal = "Normal",
8
+ UnsavedChanges = "UnsavedChanges",
9
+ Blocked = "Blocked"
10
+ }
11
+ export default BackState;
package/dist/index.d.ts CHANGED
@@ -967,6 +967,75 @@ declare const combineClassNames: (classNames: (string | undefined | null | false
967
967
  */
968
968
  declare const useForceRender: (useReducer: any) => () => void;
969
969
 
970
+ /**
971
+ * State of the current subpanel, determining what happens when the user tries
972
+ * to go back to the home screen
973
+ * @author Yuen Ler Chow
974
+ */
975
+ declare enum BackState {
976
+ Normal = "Normal",
977
+ UnsavedChanges = "UnsavedChanges",
978
+ Blocked = "Blocked"
979
+ }
980
+
981
+ /**
982
+ * Controller for the back button, used to drive back navigation from anywhere
983
+ * in the app. Pass this to subpanels so they can describe their state and
984
+ * send the user home.
985
+ * @author Yuen Ler Chow
986
+ */
987
+ type BackButtonController = {
988
+ /**
989
+ * Call this when the user navigates to a child of the home screen (something
990
+ * they can come back from)
991
+ */
992
+ onSubpanelEntered: () => void;
993
+ /**
994
+ * Send the user back to the home screen
995
+ * @param [force] if true, go home immediately without checking the subpanel
996
+ * state (no confirmation, not blocked). If falsy, nothing happens when
997
+ * blocked and confirmation is required when there are unsaved changes
998
+ */
999
+ goHome: (force?: boolean) => Promise<void>;
1000
+ /**
1001
+ * Set the state of the current subpanel, which determines what happens when
1002
+ * the user tries to go back
1003
+ * @param newSubpanelState the new state of the subpanel
1004
+ */
1005
+ setSubpanelState: (newSubpanelState: BackState) => void;
1006
+ /**
1007
+ * Set the confirmation message shown if the user tries to go back while there
1008
+ * are unsaved changes (cleared upon returning to the home screen)
1009
+ * @param message the message to show
1010
+ */
1011
+ setCustomUnsavedChangesMessage: (message: string) => void;
1012
+ /**
1013
+ * Set the message shown if the user tries to go back while blocked (cleared
1014
+ * upon returning to the home screen)
1015
+ * @param message the message to show
1016
+ */
1017
+ setCustomBlockedMessage: (message: string) => void;
1018
+ };
1019
+
1020
+ /**
1021
+ * Controller for driving back navigation from anywhere in the app. Requires
1022
+ * useBackButton to have been called in the top-level app
1023
+ * @author Yuen Ler Chow
1024
+ */
1025
+ declare const backButtonController: BackButtonController;
1026
+ /**
1027
+ * Hook that makes the browser's back button navigate within the app instead of
1028
+ * leaving it. Call this once in your top-level app, then use
1029
+ * backButtonController to enter subpanels and describe their state.
1030
+ *
1031
+ * Assumes a single level of navigation: one home screen plus subpanels that the
1032
+ * user returns home from.
1033
+ * @author Yuen Ler Chow
1034
+ * @param handleGoHomeFunc handler that performs the app state changes required
1035
+ * to return to the home screen
1036
+ */
1037
+ declare const useBackButton: (handleGoHomeFunc: () => void) => void;
1038
+
970
1039
  /**
971
1040
  * Checks if the current user is a select admin
972
1041
  * @author Gardenia Liu
@@ -974,4 +1043,4 @@ declare const useForceRender: (useReducer: any) => () => void;
974
1043
  */
975
1044
  declare const isSelectAdmin: () => Promise<boolean>;
976
1045
 
977
- export { AppWrapper, AutoscrollToBottomContainer, ButtonInputGroup, CSVDownloadButton, CheckboxButton, CopiableBox, DBEntry, DBEntryField, DBEntryFieldType, DBEntryManagerPanel, Drawer, Dropdown, DropdownItemType, DynamicWord, ErrorBox, FakeProgressBar, IntelliTable, IntelliTableColumn, ItemPicker, LoadingSpinner, LogReviewer, Modal, ModalButtonType, ModalSize, ModalType, MultiSwitch, PickableItem, PopFailureMark, PopPendingMark, PopSuccessMark, ProgressBar, ProgressBarSize, RadioButton, SimpleDateChooser, SimpleMonthChooser, SimpleTimeChooser, TabBox, ToggleSwitch, Tooltip, Variant, addFatalErrorHandler, alert, canReviewLogs, combineClassNames, confirm, initClient, isMobileOrTablet, isSelectAdmin, leaveToURL, logClientEvent, makeLinksClickable, prompt, setClientEventMetadataPopulator, showFatalError, stubServerEndpoint, useForceRender, visitServerEndpoint };
1046
+ export { AppWrapper, AutoscrollToBottomContainer, BackButtonController, BackState, ButtonInputGroup, CSVDownloadButton, CheckboxButton, CopiableBox, DBEntry, DBEntryField, DBEntryFieldType, DBEntryManagerPanel, Drawer, Dropdown, DropdownItemType, DynamicWord, ErrorBox, FakeProgressBar, IntelliTable, IntelliTableColumn, ItemPicker, LoadingSpinner, LogReviewer, Modal, ModalButtonType, ModalSize, ModalType, MultiSwitch, PickableItem, PopFailureMark, PopPendingMark, PopSuccessMark, ProgressBar, ProgressBarSize, RadioButton, SimpleDateChooser, SimpleMonthChooser, SimpleTimeChooser, TabBox, ToggleSwitch, Tooltip, Variant, addFatalErrorHandler, alert, backButtonController, canReviewLogs, combineClassNames, confirm, initClient, isMobileOrTablet, isSelectAdmin, leaveToURL, logClientEvent, makeLinksClickable, prompt, setClientEventMetadataPopulator, showFatalError, stubServerEndpoint, useBackButton, useForceRender, visitServerEndpoint };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dce-reactkit",
3
- "version": "5.0.8",
3
+ "version": "5.0.9",
4
4
  "description": "Shared components for Harvard DCE apps",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -258,48 +258,50 @@ const NestableItemList: React.FC<Props> = (props) => {
258
258
 
259
259
  return (
260
260
  <div key={item.id}>
261
- {/* Dropdown Button */}
262
- <span
263
- className="NestableItemList-dropdown-button-container d-inline-block"
264
- style={{
265
- minWidth: '2rem',
266
- }}
267
- >
268
- {item.isGroup && (
269
- <button
270
- className={`NestableItemList-dropdown-button NestableItemList-dropdown-button-${item.id}`}
271
- style={{
272
- border: 0,
273
- backgroundColor: 'transparent',
274
- }}
275
- type="button"
276
- onClick={() => {
277
- dispatch({
278
- type: ActionType.ToggleChild,
279
- id: item.id,
280
- });
281
- }}
282
- aria-label={`${childExpanded[item.id] ? 'Hide' : 'Show'} items in ${accessibleName}`}
283
- >
284
- <FontAwesomeIcon
285
- icon={childExpanded[item.id] ? faChevronDown : faChevronRight}
286
- />
287
- </button>
288
- )}
289
- </span>
290
-
291
- {/* Checkbox and Text (optionally wrapped in a tooltip) */}
292
- {
293
- item.tooltip
294
- ? (
295
- <Tooltip text={item.tooltip}>
296
- <span className="d-inline-block">
297
- {checkbox}
298
- </span>
299
- </Tooltip>
300
- )
301
- : checkbox
302
- }
261
+ <div className="NestableItemList-item d-flex align-items-center">
262
+ {/* Dropdown Button */}
263
+ <span
264
+ className="NestableItemList-dropdown-button-container"
265
+ style={{
266
+ minWidth: '2rem',
267
+ }}
268
+ >
269
+ {item.isGroup && (
270
+ <button
271
+ className={`NestableItemList-dropdown-button NestableItemList-dropdown-button-${item.id}`}
272
+ style={{
273
+ border: 0,
274
+ backgroundColor: 'transparent',
275
+ }}
276
+ type="button"
277
+ onClick={() => {
278
+ dispatch({
279
+ type: ActionType.ToggleChild,
280
+ id: item.id,
281
+ });
282
+ }}
283
+ aria-label={`${childExpanded[item.id] ? 'Hide' : 'Show'} items in ${accessibleName}`}
284
+ >
285
+ <FontAwesomeIcon
286
+ icon={childExpanded[item.id] ? faChevronDown : faChevronRight}
287
+ />
288
+ </button>
289
+ )}
290
+ </span>
291
+
292
+ {/* Checkbox and Text (optionally wrapped in a tooltip) */}
293
+ {
294
+ item.tooltip
295
+ ? (
296
+ <Tooltip text={item.tooltip}>
297
+ <span className="d-inline-block">
298
+ {checkbox}
299
+ </span>
300
+ </Tooltip>
301
+ )
302
+ : checkbox
303
+ }
304
+ </div>
303
305
 
304
306
  {/* Children */}
305
307
  {(item.isGroup && childExpanded[item.id]) && (
@@ -0,0 +1,352 @@
1
+ // Import React
2
+ import { useEffect } from 'react';
3
+
4
+ // Import components
5
+ import { alert, confirm } from '../components/AppWrapper';
6
+
7
+ // Import types
8
+ import BackState from '../types/BackState';
9
+ import BackButtonController from '../types/BackButtonController';
10
+
11
+ /*------------------------------------------------------------------------*/
12
+ /* ------------------------------ Constants ----------------------------- */
13
+ /*------------------------------------------------------------------------*/
14
+
15
+ // Marker stored on history entries owned by this hook
16
+ const HISTORY_STATE_MARKER = { dceReactKitBackButton: true };
17
+
18
+ // Default message shown when the user tries to go back while blocked
19
+ const BLOCKED_TITLE = 'Cannot Go Back';
20
+ const BLOCKED_MESSAGE = 'A task is currently in progress. Please try again once it finishes.';
21
+
22
+ // Default message shown when the user tries to go back with unsaved changes
23
+ const UNSAVED_CHANGES_TITLE = 'Abandon Changes?';
24
+ const UNSAVED_CHANGES_MESSAGE = 'Any unsaved changes may be lost.';
25
+
26
+ /*------------------------------------------------------------------------*/
27
+ /* -------------------------------- Types ------------------------------- */
28
+ /*------------------------------------------------------------------------*/
29
+
30
+ /**
31
+ * All state for the back button, kept in one place because the pieces are
32
+ * interdependent. A subpanel's back state and its custom messages only have
33
+ * meaning while the user is inside a subpanel, so they are nested within it:
34
+ * returning home is then a single change (dropping the subpanel) that cannot
35
+ * leave a stale message or back state behind
36
+ * @author Yuen Ler Chow
37
+ */
38
+ type BackButtonState = {
39
+ // Handler that performs the app state changes required to return to the home
40
+ // screen. Undefined until useBackButton has been called
41
+ handleGoHome?: () => void,
42
+ // The subpanel the user is currently in. Undefined while on the home screen
43
+ subpanel?: {
44
+ // What should happen when the user tries to go back
45
+ backState: BackState,
46
+ // Message shown when confirming that unsaved changes will be lost
47
+ customUnsavedChangesMessage?: string,
48
+ // Message shown when the user tries to go back while blocked
49
+ customBlockedMessage?: string,
50
+ },
51
+ // True while a prompt is on screen, so that repeated back presses don't stack
52
+ // up duplicate prompts
53
+ promptVisible: boolean,
54
+ // True if the next back navigation was triggered by us and must pass through
55
+ // without being intercepted
56
+ bypassNextPop: boolean,
57
+ };
58
+
59
+ /*------------------------------------------------------------------------*/
60
+ /* --------------------------- Static State ----------------------------- */
61
+ /*------------------------------------------------------------------------*/
62
+
63
+ // Current state, stored statically so that any subpanel can drive the back
64
+ // button without prop drilling or context
65
+ let state: BackButtonState = {
66
+ promptVisible: false,
67
+ bypassNextPop: false,
68
+ };
69
+
70
+ /*------------------------------------------------------------------------*/
71
+ /* ------------------------- Helper Functions --------------------------- */
72
+ /*------------------------------------------------------------------------*/
73
+
74
+ /**
75
+ * Get the app's go-home handler, throwing if the hook has not been set up yet
76
+ * @author Yuen Ler Chow
77
+ * @returns handler that returns the app to its home screen
78
+ */
79
+ const getHandleGoHome = () => {
80
+ if (!state.handleGoHome) {
81
+ throw new Error('Cannot use the back button: call useBackButton in your top-level app before using backButtonController.');
82
+ }
83
+ return state.handleGoHome;
84
+ };
85
+
86
+ /**
87
+ * Return to the home screen: clear the current subpanel and run the app's
88
+ * go-home handler
89
+ * @author Yuen Ler Chow
90
+ * @param consumeHistoryEntry if true, also step back over the history entry
91
+ * that was added when the subpanel was entered
92
+ */
93
+ const returnHome = (consumeHistoryEntry: boolean) => {
94
+ const handleGoHome = getHandleGoHome();
95
+ const wasInSubpanel = !!state.subpanel;
96
+
97
+ // Dropping the subpanel clears its back state and custom messages at once
98
+ state.subpanel = undefined;
99
+
100
+ // Update the app
101
+ handleGoHome();
102
+
103
+ // Entering a subpanel added a history entry. When the user leaves via an
104
+ // in-app control, that entry is still on the stack, so we step over it to keep
105
+ // the browser history in sync with the app. When the browser's back button is
106
+ // what brought us here, that entry has already been consumed by the browser,
107
+ // and stepping back again would take the user out of the app entirely
108
+ if (consumeHistoryEntry && wasInSubpanel) {
109
+ state.bypassNextPop = true;
110
+ window.history.back();
111
+ }
112
+ };
113
+
114
+ /**
115
+ * Keep the user in place after a back navigation that should not be allowed.
116
+ * The browser's popstate event is not cancelable, so the only way to stay put
117
+ * is to immediately push a new entry to replace the one that was just popped.
118
+ * This has to happen synchronously while handling the pop, before awaiting
119
+ * anything, otherwise the navigation has already taken effect
120
+ * @author Yuen Ler Chow
121
+ */
122
+ const undoPop = () => {
123
+ window.history.pushState(HISTORY_STATE_MARKER, '');
124
+ };
125
+
126
+ /**
127
+ * Tell the user that they cannot go back right now
128
+ * @author Yuen Ler Chow
129
+ */
130
+ const showBlockedMessage = async () => {
131
+ state.promptVisible = true;
132
+ await alert(
133
+ BLOCKED_TITLE,
134
+ state.subpanel?.customBlockedMessage ?? BLOCKED_MESSAGE,
135
+ );
136
+ state.promptVisible = false;
137
+ };
138
+
139
+ /**
140
+ * Ask the user whether they want to leave despite having unsaved changes
141
+ * @author Yuen Ler Chow
142
+ * @returns true if the user wants to leave
143
+ */
144
+ const askToAbandonChanges = async (): Promise<boolean> => {
145
+ state.promptVisible = true;
146
+ const confirmed = await confirm(
147
+ UNSAVED_CHANGES_TITLE,
148
+ state.subpanel?.customUnsavedChangesMessage ?? UNSAVED_CHANGES_MESSAGE,
149
+ {
150
+ confirmButtonText: 'Abandon Changes',
151
+ cancelButtonText: 'Stay Here',
152
+ },
153
+ );
154
+ state.promptVisible = false;
155
+ return confirmed;
156
+ };
157
+
158
+ /**
159
+ * Handle a back navigation performed by the browser
160
+ * @author Yuen Ler Chow
161
+ */
162
+ const handlePopState = () => {
163
+ // Back navigation that we triggered ourselves: let it through
164
+ if (state.bypassNextPop) {
165
+ state.bypassNextPop = false;
166
+ return;
167
+ }
168
+
169
+ // Already on the home screen: nothing for us to intercept
170
+ if (!state.subpanel) {
171
+ return;
172
+ }
173
+
174
+ // A prompt is already on screen: stay put instead of stacking another one
175
+ if (state.promptVisible) {
176
+ undoPop();
177
+ return;
178
+ }
179
+
180
+ // Blocked: stay put and explain why
181
+ if (state.subpanel.backState === BackState.Blocked) {
182
+ undoPop();
183
+ showBlockedMessage();
184
+ return;
185
+ }
186
+
187
+ // Unsaved changes: stay put until the user confirms. The pop is undone
188
+ // asynchronously here, before awaiting the confirmation, so that the user
189
+ // remains in the subpanel while they decide
190
+ if (state.subpanel.backState === BackState.UnsavedChanges) {
191
+ undoPop();
192
+ (async () => {
193
+ if (await askToAbandonChanges()) {
194
+ returnHome(true);
195
+ }
196
+ })();
197
+ return;
198
+ }
199
+
200
+ // Normal: allow it. The browser already consumed the history entry, so there
201
+ // is nothing left for us to step over
202
+ returnHome(false);
203
+ };
204
+
205
+ /*------------------------------------------------------------------------*/
206
+ /* ------------------------------ Controller ---------------------------- */
207
+ /*------------------------------------------------------------------------*/
208
+
209
+ /**
210
+ * Controller for driving back navigation from anywhere in the app. Requires
211
+ * useBackButton to have been called in the top-level app
212
+ * @author Yuen Ler Chow
213
+ */
214
+ export const backButtonController: BackButtonController = {
215
+ /**
216
+ * Call this when the user navigates to a child of the home screen (something
217
+ * they can come back from)
218
+ * @author Yuen Ler Chow
219
+ */
220
+ onSubpanelEntered: () => {
221
+ getHandleGoHome();
222
+
223
+ state.subpanel = {
224
+ backState: BackState.Normal,
225
+ };
226
+
227
+ // Add a history entry to come back to, so that the next back navigation is
228
+ // intercepted instead of leaving the app
229
+ window.history.pushState(HISTORY_STATE_MARKER, '');
230
+ },
231
+
232
+ /**
233
+ * Send the user back to the home screen
234
+ * @author Yuen Ler Chow
235
+ * @param [force] if true, go home immediately without checking the subpanel
236
+ * state. If falsy, nothing happens while blocked and confirmation is
237
+ * required when there are unsaved changes
238
+ */
239
+ goHome: async (force?: boolean) => {
240
+ getHandleGoHome();
241
+
242
+ // Unless the caller is forcing the navigation, the subpanel's state decides
243
+ // whether the user may leave: a blocked subpanel refuses and explains why,
244
+ // and one with unsaved changes leaves only if the user confirms
245
+ if (!force) {
246
+ // A prompt is already asking the user this same question
247
+ if (state.promptVisible) {
248
+ return;
249
+ }
250
+
251
+ if (state.subpanel?.backState === BackState.Blocked) {
252
+ await showBlockedMessage();
253
+ return;
254
+ }
255
+
256
+ if (state.subpanel?.backState === BackState.UnsavedChanges) {
257
+ const confirmed = await askToAbandonChanges();
258
+ if (!confirmed) {
259
+ return;
260
+ }
261
+ }
262
+ }
263
+
264
+ returnHome(true);
265
+ },
266
+
267
+ /**
268
+ * Set the state of the current subpanel, which determines what happens when
269
+ * the user tries to go back. Ignored while on the home screen, where there
270
+ * is no back navigation to describe
271
+ * @author Yuen Ler Chow
272
+ * @param newSubpanelState the new state of the subpanel
273
+ */
274
+ setSubpanelState: (newSubpanelState: BackState) => {
275
+ if (!state.subpanel) {
276
+ return;
277
+ }
278
+ state.subpanel.backState = newSubpanelState;
279
+ },
280
+
281
+ /**
282
+ * Set the confirmation message shown if the user tries to go back while there
283
+ * are unsaved changes. Cleared upon returning to the home screen
284
+ * @author Yuen Ler Chow
285
+ * @param message the message to show
286
+ */
287
+ setCustomUnsavedChangesMessage: (message: string) => {
288
+ if (!state.subpanel) {
289
+ return;
290
+ }
291
+ state.subpanel.customUnsavedChangesMessage = message;
292
+ },
293
+
294
+ /**
295
+ * Set the message shown if the user tries to go back while blocked. Cleared
296
+ * upon returning to the home screen
297
+ * @author Yuen Ler Chow
298
+ * @param message the message to show
299
+ */
300
+ setCustomBlockedMessage: (message: string) => {
301
+ if (!state.subpanel) {
302
+ return;
303
+ }
304
+ state.subpanel.customBlockedMessage = message;
305
+ },
306
+ };
307
+
308
+ /*------------------------------------------------------------------------*/
309
+ /* --------------------------------- Hook ------------------------------- */
310
+ /*------------------------------------------------------------------------*/
311
+
312
+ /**
313
+ * Hook that makes the browser's back button navigate within the app instead of
314
+ * leaving it. Call this once in your top-level app, then use
315
+ * backButtonController to enter subpanels and describe their state.
316
+ *
317
+ * Assumes a single level of navigation: one home screen plus subpanels that the
318
+ * user returns home from.
319
+ * @author Yuen Ler Chow
320
+ * @param handleGoHomeFunc handler that performs the app state changes required
321
+ * to return to the home screen
322
+ */
323
+ const useBackButton = (handleGoHomeFunc: () => void) => {
324
+ // Store the handler on every render so that the listener below, which is only
325
+ // registered once, always calls the app's current version of it
326
+ state.handleGoHome = handleGoHomeFunc;
327
+
328
+ // The popstate listener has to be attached to the window, which is outside of
329
+ // React, so an effect is used to add it when the app mounts and remove it if
330
+ // the app ever unmounts. The empty dependency array keeps this to a single
331
+ // listener for the lifetime of the app instead of one per render
332
+ useEffect(
333
+ () => {
334
+ // Mark the current entry as the home entry
335
+ window.history.replaceState(HISTORY_STATE_MARKER, '');
336
+
337
+ window.addEventListener('popstate', handlePopState);
338
+ return () => {
339
+ window.removeEventListener('popstate', handlePopState);
340
+
341
+ // Clear the static state so that a remount starts from scratch
342
+ state = {
343
+ promptVisible: false,
344
+ bypassNextPop: false,
345
+ };
346
+ };
347
+ },
348
+ [],
349
+ );
350
+ };
351
+
352
+ export default useBackButton;
package/src/index.ts CHANGED
@@ -121,6 +121,7 @@ import isMobileOrTablet from './helpers/isMobileOrTablet';
121
121
  import makeLinksClickable from './helpers/makeLinksClickable';
122
122
  import combineClassNames from './helpers/combineClassNames';
123
123
  import useForceRender from './helpers/useForceRender';
124
+ import useBackButton, { backButtonController } from './helpers/useBackButton';
124
125
  import isSelectAdmin from './helpers/isSelectAdmin';
125
126
 
126
127
  // Import types
@@ -131,6 +132,8 @@ import Variant from './types/Variant';
131
132
  import IntelliTableColumn from './types/IntelliTableColumn';
132
133
  import DropdownItemType from './types/DropdownItemType';
133
134
  import ProgressBarSize from './types/ProgressBarSize';
135
+ import BackState from './types/BackState';
136
+ import BackButtonController from './types/BackButtonController';
134
137
 
135
138
  // Component-specific-types
136
139
  import PickableItem from './components/ItemPicker/types/PickableItem';
@@ -190,6 +193,8 @@ export {
190
193
  leaveToURL,
191
194
  combineClassNames,
192
195
  useForceRender,
196
+ useBackButton,
197
+ backButtonController,
193
198
  setClientEventMetadataPopulator,
194
199
  // Types
195
200
  ModalButtonType,
@@ -200,6 +205,8 @@ export {
200
205
  DropdownItemType,
201
206
  LogReviewerFilterState,
202
207
  ProgressBarSize,
208
+ BackState,
209
+ BackButtonController,
203
210
  // Component-specific-types
204
211
  PickableItem,
205
212
  DBEntry,