From fa27649fe37272d4eac5ce1e11acdb50bb15e021 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Wed, 19 Oct 2022 16:05:55 -0500 Subject: [PATCH] toasterTypes: change toaster to ref Instead of rendering the toaster separate from the main index, add it there so we can inherit all of the context providers. This requires passing the ref object instead of the toaster instance itself, so sagas have to be updated. --- src/alerts.ts | 2 +- src/alerts/UnexpectedErrorAlert.tsx | 2 +- src/alerts/sagas.test.ts | 2 +- src/alerts/sagas.ts | 8 ++-- src/ble/alerts/BluetoothNotAvailable.tsx | 2 +- src/ble/alerts/MissingService.tsx | 2 +- src/ble/alerts/NoGatt.tsx | 2 +- src/ble/alerts/NoHub.tsx | 2 +- src/ble/alerts/NoWebBluetooth.tsx | 2 +- src/ble/alerts/OldFirmware.tsx | 2 +- src/explorer/alerts/FileInUseAlert.tsx | 2 +- src/explorer/alerts/NoFilesToBackup.tsx | 2 +- src/firmware/alerts/DfuError.tsx | 2 +- src/firmware/alerts/FirmwareMismatch.tsx | 2 +- src/firmware/alerts/FlashProgress.tsx | 2 +- src/firmware/alerts/NoDfuHub.tsx | 2 +- src/firmware/alerts/NoDfuInterface.tsx | 2 +- src/firmware/alerts/NoWebUsb.tsx | 2 +- src/firmware/alerts/ReleaseButton.tsx | 2 +- src/firmware/sagas.test.ts | 46 +++++++++---------- src/firmware/sagas.ts | 11 +++-- src/i18nToaster.tsx | 57 ------------------------ src/index.tsx | 9 ++-- src/notifications/sagas.test.ts | 23 +++++++--- src/notifications/sagas.ts | 39 +++++++--------- src/sagas.ts | 3 +- src/toasterTypes.tsx | 30 +++++++++++++ 27 files changed, 121 insertions(+), 141 deletions(-) delete mode 100644 src/i18nToaster.tsx create mode 100644 src/toasterTypes.tsx 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 };