From 6d81da0df03122475e9d0397058d5032c2517da2 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 9 Jun 2020 15:55:49 -0500 Subject: [PATCH] Show notification when program is changed in another window --- src/actions/editor.ts | 37 ++++++++++++++++++++++- src/components/Editor.tsx | 30 ++++++++++++++++--- src/components/Notification.tsx | 45 ++++++++++++++++++---------- src/components/NotificationStack.tsx | 1 + src/components/notification.en.json | 4 +++ src/reducers/notification.ts | 21 ++++++++++++- src/sagas/editor.ts | 19 ++++++++++++ src/sagas/index.ts | 3 +- 8 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 src/sagas/editor.ts diff --git a/src/actions/editor.ts b/src/actions/editor.ts index 98348da9..815173bd 100644 --- a/src/actions/editor.ts +++ b/src/actions/editor.ts @@ -17,6 +17,14 @@ export enum EditorActionType { * Open a file. */ Open = 'editor.action.open', + /** + * Storage was changed outside of the app. + */ + StorageChanged = 'editor.action.storageChanged', + /** + * Reload program from local storage. + */ + ReloadProgram = 'editor.action.reloadProgram', } export interface CurrentEditorAction extends Action { @@ -61,7 +69,34 @@ export function open(data: ArrayBuffer): EditorOpenAction { return { type: EditorActionType.Open, data }; } +/**Action that indicates the local storage has changed. */ +export interface EditorStorageChangedAction + extends Action { + newValue: string; +} + +/** + * Creates an action that indicates the local storage has changed. + * @param newValue The new program. + */ +export function storageChanged(newValue: string): EditorStorageChangedAction { + return { type: EditorActionType.StorageChanged, newValue }; +} + +/** Action to request reloading the program from local storage. */ +export type EditorReloadProgramAction = Action; + +/** Creates and action to request reloading the program from local storage. */ +export function reloadProgram(): EditorReloadProgramAction { + return { type: EditorActionType.ReloadProgram }; +} + /** * Common type for all editor actions. */ -export type EditorAction = CurrentEditorAction | EditorOpenAction | EditorSaveAsAction; +export type EditorAction = + | CurrentEditorAction + | EditorOpenAction + | EditorSaveAsAction + | EditorStorageChangedAction + | EditorReloadProgramAction; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 77a31cd7..8db3e283 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -10,7 +10,7 @@ import React from 'react'; import AceEditor from 'react-ace'; import { connect } from 'react-redux'; import { Action, Dispatch } from '../actions'; -import { setEditSession } from '../actions/editor'; +import { setEditSession, storageChanged } from '../actions/editor'; import { EditorStringId } from './editor'; import en from './editor.en.json'; @@ -20,7 +20,10 @@ import 'ace-builds/src-noconflict/ext-searchbox'; import 'ace-builds/src-noconflict/ext-keybinding_menu'; import 'ace-builds/src-noconflict/ext-language_tools'; -type DispatchProps = { onSessionChanged: (session?: Ace.EditSession) => void }; +type DispatchProps = { + onSessionChanged: (session?: Ace.EditSession) => void; + onProgramStorageChanged: (newValue: string) => void; +}; type EditorProps = DispatchProps & WithI18nProps; @@ -34,6 +37,24 @@ class Editor extends React.Component { this.editorRef = React.createRef(); } + onStorage = (e: StorageEvent): void => { + if ( + e.key === 'program' && + e.newValue && + e.newValue !== this.editorRef.current?.editor.getValue() + ) { + this.props.onProgramStorageChanged(e.newValue); + } + }; + + componentDidMount(): void { + window.addEventListener('storage', this.onStorage); + } + + componentWillUnmount(): void { + window.removeEventListener('storage', this.onStorage); + } + render(): JSX.Element { const { i18n, onSessionChanged } = this.props; const editor = this.editorRef.current?.editor; @@ -49,7 +70,7 @@ class Editor extends React.Component { height="100%" focus={true} placeholder={i18n.translate(EditorStringId.Placeholder)} - defaultValue={sessionStorage.getItem('program') || undefined} + defaultValue={localStorage.getItem('program') || undefined} editorProps={{ $blockScrolling: true }} setOptions={{ enableBasicAutocompletion: true, @@ -70,7 +91,7 @@ class Editor extends React.Component { onSessionChanged(e?.session); }} onChange={(v): void => { - sessionStorage.setItem('program', v); + localStorage.setItem('program', v); }} commands={[ { @@ -141,6 +162,7 @@ class Editor extends React.Component { const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ onSessionChanged: (s): Action => dispatch(setEditSession(s)), + onProgramStorageChanged: (v): Action => dispatch(storageChanged(v)), }); export default connect( diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 72b292c0..2f34cd5b 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -5,12 +5,13 @@ import { IconName, Intent, Toast } from '@blueprintjs/core'; import { WithI18nProps, withI18n } from '@shopify/react-i18n'; import React from 'react'; import { connect } from 'react-redux'; -import { Dispatch } from '../actions'; +import { Action, Dispatch } from '../actions'; import { NotificationLevel, remove } from '../actions/notification'; -import { Level } from '../reducers/notification'; +import { Level, MessageAction } from '../reducers/notification'; import en from './notification.en.json'; interface DispatchProps { + onAction: (action: Action) => void; onClose: () => void; } @@ -20,6 +21,7 @@ interface OwnProps { message?: string; messageId?: string; helpUrl?: string; + action?: MessageAction; } type NotificationProps = DispatchProps & OwnProps & WithI18nProps; @@ -52,25 +54,33 @@ function mapIcon(level: NotificationLevel): IconName | undefined { class Notification extends React.Component { render(): JSX.Element { + const { + action, + helpUrl, + i18n, + level, + message, + messageId, + onAction, + onClose, + } = this.props; return ( { - this.props.onClose(); - }} + onDismiss={(): void => onClose()} timeout={0} - intent={mapIntent(this.props.level)} - icon={mapIcon(this.props.level)} + intent={mapIntent(level)} + icon={mapIcon(level)} message={

- {this.props.messageId - ? this.props.i18n.translate(this.props.messageId) - : this.props.message || 'missing message!'} + {messageId + ? i18n.translate(messageId) + : message || 'missing message!'}

- {this.props.helpUrl && ( + {helpUrl && (

@@ -80,15 +90,20 @@ class Notification extends React.Component { )}

} + action={ + action && { + text: i18n.translate(action.titleId), + onClick: (): void => onAction(action.action), + } + } /> ); } } const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps): DispatchProps => ({ - onClose: (): void => { - dispatch(remove(ownProps.id)); - }, + onAction: (a): Action => dispatch(a), + onClose: (): Action => dispatch(remove(ownProps.id)), }); export default connect( diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx index 67d1404e..83e3d277 100644 --- a/src/components/NotificationStack.tsx +++ b/src/components/NotificationStack.tsx @@ -26,6 +26,7 @@ class NotificationStack extends React.Component { message={n.message} messageId={n.messageId} helpUrl={n.helpUrl} + action={n.action} /> ))} diff --git a/src/components/notification.en.json b/src/components/notification.en.json index 38f03978..300a4b21 100644 --- a/src/components/notification.en.json +++ b/src/components/notification.en.json @@ -5,6 +5,10 @@ "noWebBluetooth": "This web browser does not support Web Bluetooth or it is not enabled.", "connectFailed": "Unexpected error while trying to connect. Check console log and report the error." }, + "editor": { + "programChanged": "The program was changed in another window. Do you want to delete this program and replace it with the new program?", + "yesReloadProgram": "Yes" + }, "serviceWorker": { "success": "Content is cached for offline use.", "update": "New content is available and will be used when all tabs for this page are closed.'" diff --git a/src/reducers/notification.ts b/src/reducers/notification.ts index aea5f36d..21a9f493 100644 --- a/src/reducers/notification.ts +++ b/src/reducers/notification.ts @@ -4,6 +4,7 @@ import { Reducer } from 'react'; import { combineReducers } from 'redux'; import { Action } from '../actions'; +import { EditorActionType, reloadProgram } from '../actions/editor'; import { BootloaderConnectionActionType, BootloaderConnectionFailureReason, @@ -18,8 +19,10 @@ export enum MessageId { BleConnectFailed = 'ble.connectFailed', BleGattServiceNotFound = 'ble.gattServiceNotFound', BleNoWebBluetooth = 'ble.noWebBluetooth', + ProgramChanged = 'editor.programChanged', ServiceWorkerSuccess = 'serviceWorker.success', ServiceWorkerUpdate = 'serviceWorker.update', + YesReloadProgram = 'editor.yesReloadProgram', } /** @@ -40,12 +43,18 @@ export enum Level { Info = 'info', } +export interface MessageAction { + titleId: MessageId; + action: Action; +} + export interface Notification { readonly id: number; readonly level: Level; readonly message?: string; readonly messageId?: MessageId; readonly helpUrl?: string; + readonly action?: MessageAction; } export type NotificationList = Array; @@ -57,8 +66,9 @@ function append( level: Level, messageId: MessageId, helpUrl?: string, + action?: MessageAction, ): NotificationList { - return [...state, { id: nextId(), level, messageId, helpUrl }]; + return [...state, { id: nextId(), level, messageId, helpUrl, action }]; } const list: Reducer = (state = [], action) => { @@ -88,6 +98,15 @@ const list: Reducer = (state = [], action) => { return append(state, Level.Error, MessageId.BleConnectFailed); } return state; + case EditorActionType.StorageChanged: + if (state.find((x) => x.messageId === MessageId.ProgramChanged)) { + // don't show message again if it is already shown + return state; + } + return append(state, Level.Info, MessageId.ProgramChanged, undefined, { + titleId: MessageId.YesReloadProgram, + action: reloadProgram(), + }); case MpyActionType.DidFailToCompile: return [ ...state, diff --git a/src/sagas/editor.ts b/src/sagas/editor.ts new file mode 100644 index 00000000..428eefbf --- /dev/null +++ b/src/sagas/editor.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors + +import { Ace } from 'ace-builds'; +import { select, takeEvery } from 'redux-saga/effects'; +import { EditorActionType, EditorReloadProgramAction } from '../actions/editor'; +import { RootState } from '../reducers'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function* reloadProgram(_action: EditorReloadProgramAction): Generator { + const editor = (yield select( + (s: RootState) => s.editor.current, + )) as Ace.EditSession; + editor.setValue(localStorage.getItem('program') || ''); +} + +export default function* (): Generator { + yield takeEvery(EditorActionType.ReloadProgram, reloadProgram); +} diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 6ff2fe21..1154e1b7 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -2,10 +2,11 @@ // Copyright (c) 2020 The Pybricks Authors import { all } from 'redux-saga/effects'; +import editor from './editor'; import bootloader from './lwp3-bootloader'; import mpy from './mpy'; /* istanbul ignore next */ export default function* (): Generator { - yield all([bootloader(), mpy()]); + yield all([bootloader(), editor(), mpy()]); }