From f1f8325f6f77f6046387fc4104f186cef700641f Mon Sep 17 00:00:00 2001 From: Kyle Micallef Bonnici Date: Fri, 23 Feb 2024 13:34:03 +0100 Subject: [PATCH] Feat/add confirm before close dialog (#893) * Feat: add now confirm before close dialog --- Changelog.md | 8 +- src/App/App.tsx | 2 + src/ConfirmBeforeClose/ConfirmCloseDialog.tsx | 85 ++++++++++++++++ .../confirmBeforeCloseSlice.ts | 96 +++++++++++++++++++ src/Device/deviceSetup.ts | 14 ++- src/index.ts | 6 ++ src/store.ts | 2 + 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/ConfirmBeforeClose/ConfirmCloseDialog.tsx create mode 100644 src/ConfirmBeforeClose/confirmBeforeCloseSlice.ts diff --git a/Changelog.md b/Changelog.md index 0d4d4e86c..444770908 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,13 +7,19 @@ This project does _not_ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) but contrary to it every new version is a new major version. -## 159.0.0 - UNRELEASED +## 159.0.0 - 2024-02-23 ### Added - `minWidth` parameter to `Dropdown` component. - `transparentButtonBg` parameter to `Dropdown` component. - `NumberInput` component (provides text, input, optional unit, and slider). +- Common way to queue ongoing pending tasks. If an app is closed, a dialog is + prompted to alert users before clo sing app. Redux states for this are: + - `addConfirmBeforeClose` + - `clearConfirmBeforeClose` + - `preventAppCloseUntilComplete` can be used to wrap some promise and + secure app from closing until promise is resolved ### Removed diff --git a/src/App/App.tsx b/src/App/App.tsx index e7aac893f..b4f2aa223 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -15,6 +15,7 @@ import { Reducer } from 'redux'; import { inMain as openWindow } from '../../ipc/openWindow'; import { setNrfutilLogger } from '../../nrfutil/nrfutilLogger'; import About from '../About/About'; +import ConfirmCloseDialog from '../ConfirmBeforeClose/ConfirmCloseDialog'; import BrokenDeviceDialog from '../Device/BrokenDeviceDialog/BrokenDeviceDialog'; import { setAutoReselect } from '../Device/deviceAutoSelectSlice'; import { @@ -181,6 +182,7 @@ const ConnectedApp: FC = ({ + {children} ); diff --git a/src/ConfirmBeforeClose/ConfirmCloseDialog.tsx b/src/ConfirmBeforeClose/ConfirmCloseDialog.tsx new file mode 100644 index 000000000..424970fb6 --- /dev/null +++ b/src/ConfirmBeforeClose/ConfirmCloseDialog.tsx @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2015 Nordic Semiconductor ASA + * + * SPDX-License-Identifier: LicenseRef-Nordic-4-Clause + */ + +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getCurrentWindow } from '@electron/remote'; + +import { ConfirmationDialog } from '../Dialog/Dialog'; +import { AppThunk } from '../store'; +import { + addConfirmBeforeClose, + clearConfirmBeforeClose, + ConfirmBeforeCloseApp, + getNextConfirmDialog, + getShowConfirmCloseDialog, + setShowCloseDialog, +} from './confirmBeforeCloseSlice'; + +export default () => { + const dispatch = useDispatch(); + const [confirmedDialogs, setConfirmedDialogs] = useState< + ConfirmBeforeCloseApp[] + >([]); + + const showCloseDialog = useSelector(getShowConfirmCloseDialog); + const nextConfirmDialog = useSelector(getNextConfirmDialog); + + useEffect(() => { + if (!nextConfirmDialog && showCloseDialog) { + confirmedDialogs.forEach(confirmedDialog => { + if (confirmedDialog.onClose) confirmedDialog.onClose(); + }); + setConfirmedDialogs([]); + getCurrentWindow().close(); + } + }, [nextConfirmDialog, dispatch, showCloseDialog, confirmedDialogs]); + + useEffect(() => { + const action = (ev: BeforeUnloadEvent) => + dispatch((_, getState) => { + const hasToGetExplicitConform = + getState().confirmBeforeCloseDialog.confirmCloseApp.length > + 0; + if (hasToGetExplicitConform) { + dispatch(setShowCloseDialog(true)); + ev.returnValue = true; + } + }); + + window.addEventListener('beforeunload', action, true); + + return () => { + window.removeEventListener('beforeunload', action); + }; + }, [dispatch]); + + return ( + { + if (nextConfirmDialog) { + setConfirmedDialogs([ + ...confirmedDialogs, + nextConfirmDialog, + ]); + dispatch(clearConfirmBeforeClose(nextConfirmDialog.id)); + } + }} + onCancel={() => { + dispatch(setShowCloseDialog(false)); + confirmedDialogs.forEach(confirmedDialog => + dispatch(addConfirmBeforeClose(confirmedDialog)) + ); + setConfirmedDialogs([]); + }} + > + {nextConfirmDialog?.message} + + ); +}; diff --git a/src/ConfirmBeforeClose/confirmBeforeCloseSlice.ts b/src/ConfirmBeforeClose/confirmBeforeCloseSlice.ts new file mode 100644 index 000000000..dc1611c6a --- /dev/null +++ b/src/ConfirmBeforeClose/confirmBeforeCloseSlice.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024 Nordic Semiconductor ASA + * + * SPDX-License-Identifier: LicenseRef-Nordic-4-Clause + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { v4 as uuid } from 'uuid'; + +import type { AppThunk, RootState } from '../store'; + +export interface ConfirmBeforeCloseApp { + id: string; + message: React.ReactNode; + onClose?: () => void; +} + +export interface ConfirmBeforeCloseState { + confirmCloseApp: ConfirmBeforeCloseApp[]; + showCloseDialog: boolean; +} + +const initialState: ConfirmBeforeCloseState = { + confirmCloseApp: [], + showCloseDialog: false, +}; + +const slice = createSlice({ + name: 'confirmBeforeCloseDialog', + initialState, + reducers: { + addConfirmBeforeClose( + state, + action: PayloadAction + ) { + const index = state.confirmCloseApp.findIndex( + confirmCloseApp => confirmCloseApp.id === action.payload.id + ); + + if (index !== -1) { + state.confirmCloseApp[index] = action.payload; + } else { + state.confirmCloseApp = [ + action.payload, + ...state.confirmCloseApp, + ]; + } + }, + clearConfirmBeforeClose(state, action: PayloadAction) { + state.confirmCloseApp = state.confirmCloseApp.filter( + confirmCloseApp => confirmCloseApp.id !== action.payload + ); + }, + setShowCloseDialog(state, action: PayloadAction) { + state.showCloseDialog = action.payload; + }, + }, +}); + +export const { + reducer, + actions: { + addConfirmBeforeClose, + setShowCloseDialog, + clearConfirmBeforeClose, + }, +} = slice; + +export const getNextConfirmDialog = (state: RootState) => + state.confirmBeforeCloseDialog.confirmCloseApp.length > 0 + ? state.confirmBeforeCloseDialog.confirmCloseApp[0] + : undefined; + +export const getShowConfirmCloseDialog = (state: RootState) => + state.confirmBeforeCloseDialog.showCloseDialog; + +export const preventAppCloseUntilComplete = + ( + dialogInfo: Omit, + promise: Promise, + abortController?: AbortController + ): AppThunk => + dispatch => { + const id = uuid(); + dispatch( + addConfirmBeforeClose({ + ...dialogInfo, + id, + onClose: () => { + dialogInfo.onClose?.(); + abortController?.abort(); + }, + }) + ); + promise.finally(() => dispatch(clearConfirmBeforeClose(id))); + }; diff --git a/src/Device/deviceSetup.ts b/src/Device/deviceSetup.ts index 48c4d4e47..9f848d703 100644 --- a/src/Device/deviceSetup.ts +++ b/src/Device/deviceSetup.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: LicenseRef-Nordic-4-Clause */ import { DeviceInfo } from '../../nrfutil/device/deviceInfo'; +import { preventAppCloseUntilComplete } from '../ConfirmBeforeClose/confirmBeforeCloseSlice'; import logger from '../logging'; import describeError from '../logging/describeError'; import { AppThunk, RootState } from '../store'; @@ -151,7 +152,7 @@ export const prepareDevice = if (!selectedDeviceSetup) { onFail('No firmware was selected'); // Should never happen } else { - dispatch( + const task = dispatch( selectedDeviceSetup.programDevice( (progress: number, message?: string) => { dispatch(setDeviceSetupProgress(progress)); @@ -164,6 +165,17 @@ export const prepareDevice = ) .then(onSuccessWrapper) .catch(onFail); + + dispatch( + preventAppCloseUntilComplete( + { + message: `The device is being programmed. + Closing application right now might result in some unknown behavior and might also brick the device. + Are you sure you want to continue?`, + }, + task + ) + ); } }; diff --git a/src/index.ts b/src/index.ts index 4abf6c726..2d82a9a93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,6 +120,12 @@ export { getWaitForDevice, clearWaitForDevice, } from './Device/deviceAutoSelectSlice'; + +export { + addConfirmBeforeClose, + clearConfirmBeforeClose, + preventAppCloseUntilComplete, +} from './ConfirmBeforeClose/confirmBeforeCloseSlice'; export { deviceInfo } from './Device/deviceInfo/deviceInfo'; export { isDeviceInDFUBootloader } from './Device/sdfuOperations'; export { diff --git a/src/store.ts b/src/store.ts index ac0dc2881..8b000b29e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -9,6 +9,7 @@ import { Reducer } from 'redux'; import { reducer as shortcuts } from './About/shortcutSlice'; import { reducer as appLayout } from './App/appLayout'; +import { reducer as confirmBeforeCloseDialog } from './ConfirmBeforeClose/confirmBeforeCloseSlice'; import { reducer as brokenDeviceDialog } from './Device/BrokenDeviceDialog/brokenDeviceDialogSlice'; import { reducer as deviceAutoSelect } from './Device/deviceAutoSelectSlice'; import { reducer as deviceSetup } from './Device/deviceSetupSlice'; @@ -33,6 +34,7 @@ export const rootReducerSpec = (appReducer: Reducer = noopReducer) => ({ log, shortcuts, flashMessages, + confirmBeforeCloseDialog, }); const store = (appReducer?: Reducer) =>