diff --git a/src/alerts.ts b/src/alerts.ts index 32f31825..80723053 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -6,7 +6,7 @@ import alerts from './alerts/alerts'; import ble from './ble/alerts'; import explorer from './explorer/alerts'; import firmware from './firmware/alerts'; -import { CreateToast } from './i18nToaster'; +import type { CreateToast } from './toasterTypes'; /** This collects alerts from all of the subsystems of the app */ const alertDomains = { diff --git a/src/alerts/UnexpectedErrorAlert.tsx b/src/alerts/UnexpectedErrorAlert.tsx index f84e0821..e240c0ca 100644 --- a/src/alerts/UnexpectedErrorAlert.tsx +++ b/src/alerts/UnexpectedErrorAlert.tsx @@ -12,7 +12,7 @@ import { } from '@blueprintjs/core'; import React, { useState } from 'react'; import { useId } from 'react-aria'; -import { CreateToast } from '../i18nToaster'; +import type { CreateToast } from '../toasterTypes'; import { useI18n } from './i18n'; type UnexpectedErrorAlertProps = { diff --git a/src/alerts/sagas.test.ts b/src/alerts/sagas.test.ts index 1553c7ad..858f3714 100644 --- a/src/alerts/sagas.test.ts +++ b/src/alerts/sagas.test.ts @@ -54,7 +54,7 @@ describe('handleShowAlert', () => { toaster = new TestToaster(); jest.spyOn(toaster, 'show'); jest.spyOn(toaster, 'dismiss'); - saga = new AsyncSaga(alerts, { toaster }); + saga = new AsyncSaga(alerts, { toasterRef: { current: toaster } }); }); it('should show toast', async () => { diff --git a/src/alerts/sagas.ts b/src/alerts/sagas.ts index 66a0ea69..4bda446c 100644 --- a/src/alerts/sagas.ts +++ b/src/alerts/sagas.ts @@ -1,17 +1,19 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { ToasterInstance } from '@blueprintjs/core'; import { eventChannel } from 'redux-saga'; import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro'; import { getAlertProps } from '../alerts'; +import type { ToasterRef } from '../toasterTypes'; +import { defined } from '../utils'; import { alertsDidShowAlert, alertsShowAlert } from './actions'; -export type AlertsSagaContext = { toaster: ToasterInstance }; +export type AlertsSagaContext = { toasterRef: ToasterRef }; /** Shows an alert to the user and avoids duplicate alerts. */ function* handleShowAlert(action: ReturnType): Generator { - const toaster = yield* getContext('toaster'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); const key = `${action.domain}.${action.specific}.${JSON.stringify(action.props)}`; diff --git a/src/ble/alerts/BluetoothNotAvailable.tsx b/src/ble/alerts/BluetoothNotAvailable.tsx index 23385d35..08ccd815 100644 --- a/src/ble/alerts/BluetoothNotAvailable.tsx +++ b/src/ble/alerts/BluetoothNotAvailable.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const BluetoothNotAvailable: React.VoidFunctionComponent = () => { diff --git a/src/ble/alerts/MissingService.tsx b/src/ble/alerts/MissingService.tsx index af3d67b5..74471879 100644 --- a/src/ble/alerts/MissingService.tsx +++ b/src/ble/alerts/MissingService.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; type MissingServiceProps = { diff --git a/src/ble/alerts/NoGatt.tsx b/src/ble/alerts/NoGatt.tsx index a44c8d3d..7a0d73be 100644 --- a/src/ble/alerts/NoGatt.tsx +++ b/src/ble/alerts/NoGatt.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const NoGatt: React.VoidFunctionComponent = () => { diff --git a/src/ble/alerts/NoHub.tsx b/src/ble/alerts/NoHub.tsx index 0dba0608..7f264a39 100644 --- a/src/ble/alerts/NoHub.tsx +++ b/src/ble/alerts/NoHub.tsx @@ -5,7 +5,7 @@ import './index.scss'; import { AnchorButton, Button, Intent } from '@blueprintjs/core'; import React from 'react'; import { appName, pybricksBluetoothTroubleshootingUrl } from '../../app/constants'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; import { useI18n } from './i18n'; diff --git a/src/ble/alerts/NoWebBluetooth.tsx b/src/ble/alerts/NoWebBluetooth.tsx index c7d88e5a..a9261949 100644 --- a/src/ble/alerts/NoWebBluetooth.tsx +++ b/src/ble/alerts/NoWebBluetooth.tsx @@ -3,7 +3,7 @@ import { Button, Code, Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { isIOS, isLinux } from '../../utils/os'; import { useI18n } from './i18n'; diff --git a/src/ble/alerts/OldFirmware.tsx b/src/ble/alerts/OldFirmware.tsx index 6f3a1173..de2a68cb 100644 --- a/src/ble/alerts/OldFirmware.tsx +++ b/src/ble/alerts/OldFirmware.tsx @@ -4,7 +4,7 @@ import './index.scss'; import { Button, Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; type OldFirmwareProps = { diff --git a/src/explorer/alerts/FileInUseAlert.tsx b/src/explorer/alerts/FileInUseAlert.tsx index 80d877c5..19f03b01 100644 --- a/src/explorer/alerts/FileInUseAlert.tsx +++ b/src/explorer/alerts/FileInUseAlert.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; type FileInUseAlertProps = { diff --git a/src/explorer/alerts/NoFilesToBackup.tsx b/src/explorer/alerts/NoFilesToBackup.tsx index deab7770..492ef0ae 100644 --- a/src/explorer/alerts/NoFilesToBackup.tsx +++ b/src/explorer/alerts/NoFilesToBackup.tsx @@ -3,7 +3,7 @@ import { Icon, Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const NoFilesToBackup: React.VoidFunctionComponent = () => { diff --git a/src/firmware/alerts/DfuError.tsx b/src/firmware/alerts/DfuError.tsx index 25149052..3f5aab62 100644 --- a/src/firmware/alerts/DfuError.tsx +++ b/src/firmware/alerts/DfuError.tsx @@ -3,7 +3,7 @@ import { Button, Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; type DfuErrorProps = { diff --git a/src/firmware/alerts/FirmwareMismatch.tsx b/src/firmware/alerts/FirmwareMismatch.tsx index 959e079f..dde81080 100644 --- a/src/firmware/alerts/FirmwareMismatch.tsx +++ b/src/firmware/alerts/FirmwareMismatch.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const FirmwareMismatch: React.VoidFunctionComponent = () => { diff --git a/src/firmware/alerts/FlashProgress.tsx b/src/firmware/alerts/FlashProgress.tsx index e89f929d..4cc95e7e 100644 --- a/src/firmware/alerts/FlashProgress.tsx +++ b/src/firmware/alerts/FlashProgress.tsx @@ -3,7 +3,7 @@ import { Intent, ProgressBar } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; type FlashProgressProps = { diff --git a/src/firmware/alerts/NoDfuHub.tsx b/src/firmware/alerts/NoDfuHub.tsx index 5986f746..f36d41c8 100644 --- a/src/firmware/alerts/NoDfuHub.tsx +++ b/src/firmware/alerts/NoDfuHub.tsx @@ -4,7 +4,7 @@ import { AnchorButton, Intent } from '@blueprintjs/core'; import React from 'react'; import { pybricksUsbDfuTroubleshootingUrl } from '../../app/constants'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; import { isLinux, isWindows } from '../../utils/os'; import { useI18n } from './i18n'; diff --git a/src/firmware/alerts/NoDfuInterface.tsx b/src/firmware/alerts/NoDfuInterface.tsx index f8061b10..5b3d2a7b 100644 --- a/src/firmware/alerts/NoDfuInterface.tsx +++ b/src/firmware/alerts/NoDfuInterface.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const NoDfuInterface: React.VoidFunctionComponent = () => { diff --git a/src/firmware/alerts/NoWebUsb.tsx b/src/firmware/alerts/NoWebUsb.tsx index 6a9d168a..873a1a4a 100644 --- a/src/firmware/alerts/NoWebUsb.tsx +++ b/src/firmware/alerts/NoWebUsb.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const NoWebUsb: React.VoidFunctionComponent = () => { diff --git a/src/firmware/alerts/ReleaseButton.tsx b/src/firmware/alerts/ReleaseButton.tsx index a42b6b2a..855eff0c 100644 --- a/src/firmware/alerts/ReleaseButton.tsx +++ b/src/firmware/alerts/ReleaseButton.tsx @@ -3,7 +3,7 @@ import { Intent } from '@blueprintjs/core'; import React from 'react'; -import { CreateToast } from '../../i18nToaster'; +import type { CreateToast } from '../../toasterTypes'; import { useI18n } from './i18n'; const ReleaseButton: React.VoidFunctionComponent = () => { diff --git a/src/firmware/sagas.test.ts b/src/firmware/sagas.test.ts index e66823cc..ff230720 100644 --- a/src/firmware/sagas.test.ts +++ b/src/firmware/sagas.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021-2022 The Pybricks Authors -import { ToasterInstance } from '@blueprintjs/core'; +import type { ToasterInstance } from '@blueprintjs/core'; import { FirmwareMetadata, FirmwareMetadataV110, @@ -83,7 +83,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -231,7 +231,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -361,7 +361,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -411,7 +411,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -479,7 +479,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -543,7 +543,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -610,7 +610,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -670,7 +670,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -736,7 +736,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -819,7 +819,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -885,7 +885,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -984,7 +984,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1092,7 +1092,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1240,7 +1240,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1386,7 +1386,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1538,7 +1538,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1585,7 +1585,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1631,7 +1631,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1691,7 +1691,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1752,7 +1752,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1816,7 +1816,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action @@ -1905,7 +1905,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toasterRef: { current: mock() }, }); // saga is triggered by this action diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index e015b89e..5b78916d 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2022 The Pybricks Authors -import { ToasterInstance } from '@blueprintjs/core'; import { FirmwareReader, FirmwareReaderError, @@ -56,6 +55,7 @@ import { MaxProgramFlashSize, Result } from '../lwp3-bootloader/protocol'; import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; import { compile, didCompile, didFailToCompile } from '../mpy/actions'; import { RootState } from '../reducers'; +import type { ToasterRef } from '../toasterTypes'; import { LegoUsbProductId, legoUsbVendorId } from '../usb'; import { defined, ensureError, hex, maybe } from '../utils'; import { crc32, fmod, sumComplement32 } from '../utils/math'; @@ -92,7 +92,8 @@ const firmwareZipMap = new Map([ * parent task). */ function* disconnectAndCancel(): SagaGenerator { - const toaster = yield* getContext('toaster'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); toaster.dismiss('firmware.ble.progress'); @@ -375,7 +376,8 @@ function* loadFirmware( * @param action The action that triggered this saga. */ function* handleFlashFirmware(action: ReturnType): Generator { - const toaster = yield* getContext('toaster'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); try { let firmware: Uint8Array | undefined = undefined; @@ -703,7 +705,8 @@ function* handleFlashUsbDfu(action: ReturnType): Gen dfu.dfuseStartAddress = dfuFirmwareStartAddress; const writeProc = dfu.write(1024, firmware, true); - const toaster = yield* getContext('toaster'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); defer.push( writeProc.events.on('erase/process', (sent, total) => { diff --git a/src/i18nToaster.tsx b/src/i18nToaster.tsx deleted file mode 100644 index 98415b65..00000000 --- a/src/i18nToaster.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2021-2022 The Pybricks Authors - -import { ToastProps, Toaster, ToasterInstance } from '@blueprintjs/core'; -import { I18nContext, I18nManager } from '@shopify/react-i18n'; -import React from 'react'; -import ReactDOM from 'react-dom'; - -/** - * Creates an `ToasterInstance` for static usage similar to `Toaster.create()` except - * that it is wrapped in an `I18nContext.Provider` so that messages can be - * translated. - * - * @param i18n The i18n manager object. - */ -export function create(i18n: I18nManager): ToasterInstance { - const containerElement = document.createElement('div'); - - document.body.appendChild(containerElement); - - const toaster = React.createRef(); - - ReactDOM.render( - - - , - containerElement, - ); - - // istanbul ignore if: should not happen since we are rendering the component - if (toaster.current === null) { - throw new Error('failed to set toaster ref'); - } - - return toaster.current; -} - -/** - * Template type alert callbacks. - * - * This is called when an alert is dismissed. - * - * @param action: The action that the user selected. This is usually 'dismiss'. - */ -export type ToastActionHandler = (action: A) => void; - -/** - * Template type for all toast creation functions for alert components. - * - * @param onAction A callback that is called when the toast is dismissed. - * @param props Additional properties required by this toast, if any (usually - * replacements for translations). - */ -export type CreateToast< - P extends Record = never, - A extends string = 'dismiss', -> = (onAction: ToastActionHandler, props: P) => ToastProps; diff --git a/src/index.tsx b/src/index.tsx index 6964a74f..e842f4c6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,7 @@ // Copyright (c) 2020-2022 The Pybricks Authors import './index.scss'; -import { HotkeysProvider } from '@blueprintjs/core'; +import { HotkeysProvider, Toaster } from '@blueprintjs/core'; import { configureStore } from '@reduxjs/toolkit'; import { I18nContext } from '@shopify/react-i18n'; import React from 'react'; @@ -15,7 +15,6 @@ import App from './app/App'; import { appVersion } from './app/constants'; import { db } from './fileStorage/context'; import { i18nManager } from './i18n'; -import * as I18nToaster from './i18nToaster'; import { rootReducer } from './reducers'; import reportWebVitals from './reportWebVitals'; import rootSaga, { RootSagaContext } from './sagas'; @@ -23,15 +22,14 @@ import { defaultTerminalContext } from './terminal/TerminalContext'; import ViewHeightSensor from './utils/ViewHeightSensor'; import { createCountFunc } from './utils/iter'; -const toaster = I18nToaster.create(i18nManager); +const toasterRef = React.createRef(); const sagaMiddleware = createSagaMiddleware({ context: { nextMessageId: createCountFunc(), - notification: { toaster }, terminal: defaultTerminalContext, fileStorage: db, - toaster, + toasterRef, }, }); @@ -103,6 +101,7 @@ ReactDOM.render( + , diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index b76599ea..bc73e5b2 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021-2022 The Pybricks Authors -import { ToasterInstance } from '@blueprintjs/core'; +import type { IToastOptions, ToasterInstance } from '@blueprintjs/core'; import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware'; -import { I18nManager } from '@shopify/react-i18n'; +import { mock } from 'jest-mock-extended'; import { AnyAction } from 'redux'; import { AsyncSaga, uuid } from '../../test'; import { appDidCheckForUpdate } from '../app/actions'; @@ -23,7 +23,6 @@ import { MetadataProblem, didFailToFinish, } from '../firmware/actions'; -import * as i18nToaster from '../i18nToaster'; import { BootloaderConnectionFailureReason, didFailToConnect as bootloaderDidFailToConnect, @@ -38,15 +37,27 @@ import { I18nId } from './i18n'; import notification from './sagas'; function createTestToasterSaga(): { toaster: ToasterInstance; saga: AsyncSaga } { - const i18n = new I18nManager({ locale: 'en' }); - const toaster = i18nToaster.create(i18n); + const toasts = new Map(); + + const toaster = mock({ + show: (props, key) => { + return key ?? ''; + }, + dismiss: (key) => { + toasts.delete(key); + }, + clear: () => { + toasts.clear(); + }, + getToasts: () => [...toasts.values()], + }); jest.spyOn(toaster, 'clear'); jest.spyOn(toaster, 'dismiss'); jest.spyOn(toaster, 'getToasts'); jest.spyOn(toaster, 'show'); - const saga = new AsyncSaga(notification, { notification: { toaster } }); + const saga = new AsyncSaga(notification, { toasterRef: { current: toaster } }); return { toaster, saga }; } diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 9c9b4a1c..d8aad6ae 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -3,13 +3,7 @@ // Saga for managing notifications (toasts) -import { - ActionProps, - IconName, - Intent, - LinkProps, - ToasterInstance, -} from '@blueprintjs/core'; +import { ActionProps, IconName, Intent, LinkProps } from '@blueprintjs/core'; import { Replacements } from '@shopify/react-i18n'; import React from 'react'; import { channel } from 'redux-saga'; @@ -34,22 +28,13 @@ import { } from '../lwp3-bootloader/actions'; import { didCompile, didFailToCompile } from '../mpy/actions'; import { serviceWorkerDidUpdate } from '../service-worker/actions'; +import type { ToasterRef } from '../toasterTypes'; +import { defined } from '../utils'; import NotificationAction from './NotificationAction'; import NotificationMessage from './NotificationMessage'; import { add as addNotification } from './actions'; import { I18nId } from './i18n'; -type NotificationContext = { - toaster: ToasterInstance; -}; - -/** - * Partial saga context type for context used in the notification sagas. - */ -export type NotificationSagaContext = { - notification: NotificationContext; -}; - /** Severity level of notification. */ enum Level { /** This is an error (requires user action to resolve). */ @@ -126,7 +111,8 @@ function* showSingleton( action?: ActionProps & LinkProps, onDismiss?: (didTimeoutExpire: boolean) => void, ): Generator { - const { toaster } = yield* getContext('notification'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); // if the message is already showing, close it and wait some time so that // users can see that something triggered the message again @@ -158,7 +144,9 @@ function* showSingleton( /** Shows a special notification for unexpected errors. */ function* showUnexpectedError(messageId: I18nId, error: Error): Generator { - const { toaster } = yield* getContext('notification'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); + const key = `alerts.unexpectedError.${messageId}`; toaster.show( @@ -248,7 +236,9 @@ function* showFlashFirmwareError( } function* dismissCompilerError(): Generator { - const { toaster } = yield* getContext('notification'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); + toaster.dismiss(I18nId.MpyError); } @@ -263,7 +253,8 @@ function* showCompilerError(action: ReturnType): Genera } function* handleAddNotification(action: ReturnType): Generator { - const { toaster } = yield* getContext('notification'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); toaster.show({ intent: mapIntent(action.level as Level), @@ -303,7 +294,9 @@ function* showNoUpdateInfo(action: ReturnType): Gen return; } - const { toaster } = yield* getContext('notification'); + const toaster = (yield* getContext('toasterRef')).current; + defined(toaster); + toaster.show({ intent: mapIntent(Level.Info), icon: mapIcon(Level.Info), diff --git a/src/sagas.ts b/src/sagas.ts index 59adb9a1..ca8c22c3 100644 --- a/src/sagas.ts +++ b/src/sagas.ts @@ -16,7 +16,7 @@ import hub from './hub/sagas'; import lwp3BootloaderProtocol from './lwp3-bootloader/sagas'; import lwp3BootloaderBle from './lwp3-bootloader/sagas-ble'; import mpy from './mpy/sagas'; -import notifications, { NotificationSagaContext } from './notifications/sagas'; +import notifications from './notifications/sagas'; import terminal, { TerminalSagaContext } from './terminal/sagas'; /* istanbul ignore next */ @@ -48,5 +48,4 @@ export type RootSagaContext = { nextMessageId: () => number; } & AlertsSagaContext & FileStorageSageContext & - NotificationSagaContext & TerminalSagaContext; diff --git a/src/toasterTypes.tsx b/src/toasterTypes.tsx new file mode 100644 index 00000000..3e997cbd --- /dev/null +++ b/src/toasterTypes.tsx @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2022 The Pybricks Authors + +import type { ToastProps, ToasterInstance } from '@blueprintjs/core'; + +/** + * Template type alert callbacks. + * + * This is called when an alert is dismissed. + * + * @param action: The action that the user selected. This is usually 'dismiss'. + */ +export type ToastActionHandler = (action: A) => void; + +/** + * Template type for all toast creation functions for alert components. + * + * @param onAction A callback that is called when the toast is dismissed. + * @param props Additional properties required by this toast, if any (usually + * replacements for translations). + */ +export type CreateToast< + P extends Record = never, + A extends string = 'dismiss', +> = (onAction: ToastActionHandler, props: P) => ToastProps; + +/** + * Type compatible with React.RefObject. + */ +export type ToasterRef = { current: ToasterInstance | null };