Show notification when program is changed in another window

This commit is contained in:
David Lechner
2020-06-09 23:51:32 -05:00
committed by David Lechner
parent f721f959f0
commit 6d81da0df0
8 changed files with 138 additions and 22 deletions
+36 -1
View File
@@ -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<EditorActionType.Current> {
@@ -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<EditorActionType.StorageChanged> {
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<EditorActionType.ReloadProgram>;
/** 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;
+26 -4
View File
@@ -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<EditorProps> {
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<EditorProps> {
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<EditorProps> {
onSessionChanged(e?.session);
}}
onChange={(v): void => {
sessionStorage.setItem('program', v);
localStorage.setItem('program', v);
}}
commands={[
{
@@ -141,6 +162,7 @@ class Editor extends React.Component<EditorProps> {
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
onSessionChanged: (s): Action => dispatch(setEditSession(s)),
onProgramStorageChanged: (v): Action => dispatch(storageChanged(v)),
});
export default connect(
+30 -15
View File
@@ -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<NotificationProps> {
render(): JSX.Element {
const {
action,
helpUrl,
i18n,
level,
message,
messageId,
onAction,
onClose,
} = this.props;
return (
<Toast
onDismiss={(): void => {
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={
<div>
<p>
{this.props.messageId
? this.props.i18n.translate(this.props.messageId)
: this.props.message || 'missing message!'}
{messageId
? i18n.translate(messageId)
: message || 'missing message!'}
</p>
{this.props.helpUrl && (
{helpUrl && (
<p>
<a
href={this.props.helpUrl}
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -80,15 +90,20 @@ class Notification extends React.Component<NotificationProps> {
)}
</div>
}
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(
+1
View File
@@ -26,6 +26,7 @@ class NotificationStack extends React.Component<NotificationStackProps> {
message={n.message}
messageId={n.messageId}
helpUrl={n.helpUrl}
action={n.action}
/>
))}
</Toaster>
+4
View File
@@ -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.'"
+20 -1
View File
@@ -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<Notification>;
@@ -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<NotificationList, Action> = (state = [], action) => {
@@ -88,6 +98,15 @@ const list: Reducer<NotificationList, Action> = (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,
+19
View File
@@ -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);
}
+2 -1
View File
@@ -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()]);
}