mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
Vendored
+4
-4
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"[css]": {
|
||||
"editor.suggest.insertMode": "replace",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
@@ -11,6 +7,10 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pybricks/pybricks-code",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"license": "MIT",
|
||||
"author": "The Pybricks Authors",
|
||||
"repository": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"@blueprintjs/core": "^3.38.1",
|
||||
"@craco/craco": "^6.0.0",
|
||||
"@pybricks/firmware": "4.5.0",
|
||||
"@pybricks/ide-docs": "1.0.0",
|
||||
"@pybricks/ide-docs": "1.1.0",
|
||||
"@pybricks/mpy-cross-v5": "^2.0.0",
|
||||
"@shopify/react-i18n": "^5.2.1",
|
||||
"@testing-library/dom": "^7.29.2",
|
||||
|
||||
+84
-3
@@ -4,11 +4,24 @@
|
||||
// Actions for the app in general.
|
||||
|
||||
import { Action } from 'redux';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
|
||||
/** App action types. */
|
||||
export enum AppActionType {
|
||||
/** Reload the app. */
|
||||
Reload = 'app.action.reload',
|
||||
/** Checks for an available update. */
|
||||
CheckForUpdate = 'app.action.checkForUpdate',
|
||||
/** Indicates that checking for update finished. */
|
||||
DidCheckForUpdate = 'app.action.didCheckForUpdate',
|
||||
/* Indicates the browser wants to prompt the use to install the app. */
|
||||
DidBeforeInstallPrompt = 'app.action.didBeforeInstallPrompt',
|
||||
/* Requests to prompt the user to install the app. */
|
||||
InstallPrompt = 'app.action.installPrompt',
|
||||
/* Indicates that the user responded to the install prompt. */
|
||||
DidInstallPrompt = 'app.action.didInstallPrompt',
|
||||
/* Indicates that the app was installed. */
|
||||
DidInstall = 'app.action.didInstallPrompt',
|
||||
/** The app has just ben started. */
|
||||
DidStart = 'app.action.didStart',
|
||||
/** Open settings dialog. */
|
||||
@@ -26,11 +39,73 @@ export enum AppActionType {
|
||||
}
|
||||
|
||||
/** Action that requests the app to reload. */
|
||||
export type AppReloadAction = Action<AppActionType.Reload>;
|
||||
export type AppReloadAction = Action<AppActionType.Reload> & {
|
||||
registration: ServiceWorkerRegistration;
|
||||
};
|
||||
|
||||
/** Creates an action that requests the app to reload. */
|
||||
export function reload(): AppReloadAction {
|
||||
return { type: AppActionType.Reload };
|
||||
export function reload(registration: ServiceWorkerRegistration): AppReloadAction {
|
||||
return { type: AppActionType.Reload, registration };
|
||||
}
|
||||
|
||||
/** Action that requests to check for updates. */
|
||||
export type AppCheckForUpdatesAction = Action<AppActionType.CheckForUpdate> & {
|
||||
registration: ServiceWorkerRegistration;
|
||||
};
|
||||
|
||||
/** Action that requests to check for updates. */
|
||||
export function checkForUpdate(
|
||||
registration: ServiceWorkerRegistration,
|
||||
): AppCheckForUpdatesAction {
|
||||
return { type: AppActionType.CheckForUpdate, registration };
|
||||
}
|
||||
|
||||
/** Action that indicates that checking for an update has completed. */
|
||||
export type AppDidCheckForUpdateAction = Action<AppActionType.DidCheckForUpdate> & {
|
||||
updateFound: boolean;
|
||||
};
|
||||
|
||||
/** Action that indicates that checking for an update has completed. */
|
||||
export function didCheckForUpdate(updateFound: boolean): AppDidCheckForUpdateAction {
|
||||
return { type: AppActionType.DidCheckForUpdate, updateFound };
|
||||
}
|
||||
|
||||
/* Action that indicates the browser wants to prompt the use to install the app. */
|
||||
export type AppDidBeforeInstallPromptAction = Action<AppActionType.DidBeforeInstallPrompt> & {
|
||||
event: BeforeInstallPromptEvent;
|
||||
};
|
||||
|
||||
/* Action that indicates the browser wants to prompt the use to install the app. */
|
||||
export function didBeforeInstallPrompt(
|
||||
event: BeforeInstallPromptEvent,
|
||||
): AppDidBeforeInstallPromptAction {
|
||||
return { type: AppActionType.DidBeforeInstallPrompt, event };
|
||||
}
|
||||
|
||||
/* Action that requests to prompt the user to install the app. */
|
||||
export type AppInstallPromptAction = Action<AppActionType.InstallPrompt> & {
|
||||
event: BeforeInstallPromptEvent;
|
||||
};
|
||||
|
||||
/* Action that requests to prompt the user to install the app. */
|
||||
export function installPrompt(event: BeforeInstallPromptEvent): AppInstallPromptAction {
|
||||
return { type: AppActionType.InstallPrompt, event };
|
||||
}
|
||||
|
||||
/* Action that indicates that the user responded to the install prompt. */
|
||||
export type AppDidInstallPromptAction = Action<AppActionType.DidInstallPrompt>;
|
||||
|
||||
/* Action that indicates that the user responded to the install prompt. */
|
||||
export function didInstallPrompt(): AppDidInstallPromptAction {
|
||||
return { type: AppActionType.DidInstallPrompt };
|
||||
}
|
||||
|
||||
/* Action that indicates app was installed. */
|
||||
export type AppDidInstallAction = Action<AppActionType.DidInstall>;
|
||||
|
||||
/* Action that indicates app was installed. */
|
||||
export function didInstall(): AppDidInstallAction {
|
||||
return { type: AppActionType.DidInstall };
|
||||
}
|
||||
|
||||
/** Action that indicates the app has just started. */
|
||||
@@ -92,6 +167,12 @@ export function closeLicenseDialog(): AppCloseLicenseDialogAction {
|
||||
/** common type for all app actions. */
|
||||
export type AppAction =
|
||||
| AppReloadAction
|
||||
| AppCheckForUpdatesAction
|
||||
| AppDidCheckForUpdateAction
|
||||
| AppDidBeforeInstallPromptAction
|
||||
| AppInstallPromptAction
|
||||
| AppDidInstallPromptAction
|
||||
| AppDidInstallAction
|
||||
| AppDidStartAction
|
||||
| AppOpenSettingsAction
|
||||
| AppCloseSettingsAction
|
||||
|
||||
@@ -51,6 +51,7 @@ export function didConnect(): BleDeviceDidConnectAction {
|
||||
|
||||
export enum BleDeviceFailToConnectReasonType {
|
||||
NoWebBluetooth = 'ble.device.didFailToConnect.noWebBluetooth',
|
||||
NoBluetooth = 'ble.device.didFailToConnect.noBluetooth',
|
||||
Canceled = 'ble.device.didFailToConnect.canceled',
|
||||
NoGatt = 'ble.device.didFailToConnect.noGatt',
|
||||
NoService = 'ble.device.didFailToConnect.noService',
|
||||
@@ -63,6 +64,8 @@ type Reason<T extends BleDeviceFailToConnectReasonType> = {
|
||||
|
||||
export type BleDeviceFailToConnectNoWebBluetoothReason = Reason<BleDeviceFailToConnectReasonType.NoWebBluetooth>;
|
||||
|
||||
export type BleDeviceFailToConnectNoBluetoothReason = Reason<BleDeviceFailToConnectReasonType.NoBluetooth>;
|
||||
|
||||
export type BleDeviceFailToConnectCanceledReason = Reason<BleDeviceFailToConnectReasonType.Canceled>;
|
||||
|
||||
export type BleDeviceFailToConnectNoGattReason = Reason<BleDeviceFailToConnectReasonType.NoGatt>;
|
||||
@@ -75,6 +78,7 @@ export type BleDeviceFailToConnectUnknownReason = Reason<BleDeviceFailToConnectR
|
||||
|
||||
export type BleDeviceDidFailToConnectReason =
|
||||
| BleDeviceFailToConnectNoWebBluetoothReason
|
||||
| BleDeviceFailToConnectNoBluetoothReason
|
||||
| BleDeviceFailToConnectCanceledReason
|
||||
| BleDeviceFailToConnectNoGattReason
|
||||
| BleDeviceFailToConnectNoServiceReason
|
||||
|
||||
@@ -79,6 +79,8 @@ export function disconnect(): BootloaderConnectionDisconnectAction {
|
||||
export enum BootloaderConnectionFailureReason {
|
||||
/** Web Bluetooth is not available */
|
||||
NoWebBluetooth = 'no-web-bluetooth',
|
||||
/** Bluetooth is not available */
|
||||
NoBluetooth = 'no-bluetooth',
|
||||
/** Connected but failed to find the bootloader GATT service */
|
||||
GattServiceNotFound = 'gatt-service-not-found',
|
||||
/** The connection was canceled */
|
||||
@@ -93,6 +95,8 @@ type Reason<T extends BootloaderConnectionFailureReason> = {
|
||||
|
||||
export type BootloaderConnectionFailToConnectNoWebBluetoothReason = Reason<BootloaderConnectionFailureReason.NoWebBluetooth>;
|
||||
|
||||
export type BootloaderConnectionFailToConnectNoBluetoothReason = Reason<BootloaderConnectionFailureReason.NoBluetooth>;
|
||||
|
||||
export type BootloaderConnectionFailToConnectGattServiceNotFoundReason = Reason<BootloaderConnectionFailureReason.GattServiceNotFound>;
|
||||
|
||||
export type BootloaderConnectionFailToConnectCanceledReason = Reason<BootloaderConnectionFailureReason.Canceled>;
|
||||
@@ -103,6 +107,7 @@ export type BootloaderConnectionFailToConnectUnknownReason = Reason<BootloaderCo
|
||||
|
||||
export type BootloaderConnectionDidFailToConnectReason =
|
||||
| BootloaderConnectionFailToConnectNoWebBluetoothReason
|
||||
| BootloaderConnectionFailToConnectNoBluetoothReason
|
||||
| BootloaderConnectionFailToConnectGattServiceNotFoundReason
|
||||
| BootloaderConnectionFailToConnectCanceledReason
|
||||
| BootloaderConnectionFailToConnectUnknownReason;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingId } from '../settings/user';
|
||||
/** Actions related to settings. */
|
||||
export enum SettingsActionType {
|
||||
SetBoolean = 'settings.action.setBoolean',
|
||||
ToggleBoolean = 'settings.action.toggleBoolean',
|
||||
DidFailToSetBoolean = 'settings.action.didFailToSetBoolean',
|
||||
DidBooleanChange = 'settings.action.didBooleanChange',
|
||||
}
|
||||
@@ -27,6 +28,16 @@ export function setBoolean(id: SettingId, newState: boolean): SettingsSetBoolean
|
||||
return { type: SettingsActionType.SetBoolean, id, newState };
|
||||
}
|
||||
|
||||
/** Action to toggle a setting. */
|
||||
export type SettingsToggleBooleanAction = Action<SettingsActionType.ToggleBoolean> & {
|
||||
id: SettingId;
|
||||
};
|
||||
|
||||
/** Creates an action to toggle a setting. */
|
||||
export function toggleBoolean(id: SettingId): SettingsToggleBooleanAction {
|
||||
return { type: SettingsActionType.ToggleBoolean, id };
|
||||
}
|
||||
|
||||
/** Action that indicates setting/storing a setting failed. */
|
||||
export type SettingsDidFailToSetBooleanAction = Action<SettingsActionType.DidFailToSetBoolean> & {
|
||||
id: SettingId;
|
||||
@@ -56,5 +67,6 @@ export function didBooleanChange(
|
||||
/** Common type for all settings actions. */
|
||||
export type SettingsAction =
|
||||
| SettingsSetBooleanAction
|
||||
| SettingsToggleBooleanAction
|
||||
| SettingsDidFailToSetBooleanAction
|
||||
| SettingsDidBooleanChangeAction;
|
||||
|
||||
@@ -51,6 +51,7 @@ class ActionButton extends React.Component<Props> {
|
||||
<Button
|
||||
ref={this.buttonRef}
|
||||
intent={Intent.PRIMARY}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent focus
|
||||
onClick={(): void => this.props.onAction()}
|
||||
disabled={this.props.enabled === false}
|
||||
className="no-box-shadow"
|
||||
|
||||
+113
-15
@@ -1,10 +1,14 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
|
||||
import { Classes } from '@blueprintjs/core';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import SplitterLayout from 'react-splitter-layout';
|
||||
import { toggleBoolean } from '../actions/settings';
|
||||
import { RootState } from '../reducers';
|
||||
import { SettingId } from '../settings/user';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import Editor from './Editor';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
import StatusBar from './StatusBar';
|
||||
@@ -17,12 +21,13 @@ import './app.scss';
|
||||
function App(): JSX.Element {
|
||||
const showDocs = useSelector((s: RootState): boolean => s.settings.showDocs);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Toolbar />
|
||||
<SplitterLayout
|
||||
customClassName="h-body"
|
||||
customClassName={`h-body ${showDocs ? 'pb-show-docs' : 'pb-hide-docs'}`}
|
||||
onDragStart={(): void => setDragging(true)}
|
||||
onDragEnd={(): void => setDragging(false)}
|
||||
percentage={true}
|
||||
@@ -48,19 +53,112 @@ function App(): JSX.Element {
|
||||
<Terminal />
|
||||
</div>
|
||||
</SplitterLayout>
|
||||
{showDocs && (
|
||||
<div className="h-100 w-100">
|
||||
{dragging && <div className="h-100 w-100 p-absolute" />}
|
||||
<iframe
|
||||
src="static/docs/index.html"
|
||||
allowFullScreen={true}
|
||||
title="docs"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-100 w-100">
|
||||
{dragging && <div className="h-100 w-100 p-absolute" />}
|
||||
<iframe
|
||||
// REVISIT: some of this could be moved to the docs repo
|
||||
// so that it runs earlier to prevent flashing in the UI.
|
||||
// The load event doesn't run until after the page is fully
|
||||
// loaded and there doesn't seem to be a reasonable way to
|
||||
// hook into the iframe to know when it has a new document.
|
||||
onLoad={(e) => {
|
||||
// HACK: this mess restores the scroll position when
|
||||
// the documentation iframe visibility is toggled.
|
||||
// The iframe will be automatically scrolled to 0 when
|
||||
// CSS `display: none` is set.
|
||||
|
||||
const target = e.target as HTMLIFrameElement;
|
||||
const contentWindow = target.contentWindow;
|
||||
if (!contentWindow) {
|
||||
console.error('could not get iframe content window');
|
||||
return;
|
||||
}
|
||||
|
||||
// the last "good" scrollY value of the iframe
|
||||
let iframeScroll = 0;
|
||||
|
||||
// This bit monitors the visibility.
|
||||
// https://stackoverflow.com/a/44670818/1976323
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
// Restore the scroll position when the
|
||||
// iframe is shown. Toggling the visibility
|
||||
// prevents flashing the contents from the
|
||||
// top of the page before the scroll is
|
||||
// done.
|
||||
if (entry.intersectionRatio > 0) {
|
||||
contentWindow.scrollTo(0, iframeScroll);
|
||||
contentWindow.document.documentElement.style.visibility =
|
||||
'visible';
|
||||
} else {
|
||||
contentWindow.document.documentElement.style.visibility =
|
||||
'hidden';
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: target.parentElement,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
// Have to remove he observer, otherwise we end up
|
||||
// with conflicting values when a new page is loaded
|
||||
// in the iframe.
|
||||
contentWindow.addEventListener('unload', () => {
|
||||
observer.unobserve(target);
|
||||
});
|
||||
|
||||
// And this keeps track of the scroll position.
|
||||
contentWindow.addEventListener('scroll', () => {
|
||||
if (contentWindow.scrollY !== 0) {
|
||||
// Record the current scroll position.
|
||||
// If it is 0, it could be that the iframe
|
||||
// has been hidden or the user scrolled
|
||||
// there. So we have to ignore 0. But we
|
||||
// don't want to be one pixel off if the
|
||||
// user really did scroll there, so we
|
||||
// assume that if the last scroll is 1, then
|
||||
// the user probably went all the way to 0.
|
||||
if (contentWindow.scrollY === 1) {
|
||||
iframeScroll = 0;
|
||||
} else {
|
||||
iframeScroll = contentWindow.scrollY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Override browser default key bindings in iframe.
|
||||
contentWindow.document.addEventListener('keydown', (e) => {
|
||||
// use Ctrl-D/Cmd-D to toggle docs
|
||||
if (
|
||||
(isMacOS()
|
||||
? e.metaKey && !e.ctrlKey
|
||||
: e.ctrlKey && !e.metaKey) &&
|
||||
!e.altKey &&
|
||||
e.key == 'd'
|
||||
) {
|
||||
e.preventDefault();
|
||||
dispatch(toggleBoolean(SettingId.ShowDocs));
|
||||
}
|
||||
});
|
||||
|
||||
if (document.body.classList.contains(Classes.DARK)) {
|
||||
contentWindow.document.documentElement.classList.add(
|
||||
Classes.DARK,
|
||||
);
|
||||
}
|
||||
}}
|
||||
src="static/docs/index.html"
|
||||
allowFullScreen={true}
|
||||
title="docs"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="none"
|
||||
/>
|
||||
</div>
|
||||
</SplitterLayout>
|
||||
<StatusBar />
|
||||
<SettingsDrawer />
|
||||
|
||||
@@ -17,7 +17,9 @@ import { connect } from 'react-redux';
|
||||
import { Action, Dispatch } from '../actions';
|
||||
import { setEditSession, storageChanged } from '../actions/editor';
|
||||
import { compile } from '../actions/mpy';
|
||||
import { toggleBoolean } from '../actions/settings';
|
||||
import { RootState } from '../reducers';
|
||||
import { SettingId } from '../settings/user';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { EditorStringId } from './editor-i18n';
|
||||
import en from './editor-i18n.en.json';
|
||||
@@ -34,12 +36,14 @@ import './editor.scss';
|
||||
|
||||
type StateProps = {
|
||||
darkMode: boolean;
|
||||
showDocs: boolean;
|
||||
};
|
||||
|
||||
type DispatchProps = {
|
||||
onSessionChanged: (session?: Ace.EditSession) => void;
|
||||
onProgramStorageChanged: (newValue: string) => void;
|
||||
onCheck: (script: string) => void;
|
||||
onToggleDocs: () => void;
|
||||
};
|
||||
|
||||
type EditorProps = StateProps & DispatchProps & WithI18nProps;
|
||||
@@ -78,7 +82,7 @@ class Editor extends React.Component<EditorProps> {
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const { darkMode, i18n, onSessionChanged, onCheck } = this.props;
|
||||
const { i18n, darkMode, onSessionChanged, onCheck, onToggleDocs } = this.props;
|
||||
return (
|
||||
<div className="h-100">
|
||||
<ResizeSensor onResize={(): void => this.editor?.resize()}>
|
||||
@@ -105,6 +109,13 @@ class Editor extends React.Component<EditorProps> {
|
||||
mac: 'Shift-F2',
|
||||
};
|
||||
|
||||
// we want to use Ctrl-D for docs toggle, so change
|
||||
// delete line to VSCode default
|
||||
e.commands.byName['removeline'].bindKey = {
|
||||
win: 'Ctrl-Shift-K',
|
||||
mac: 'Cmd-Shift-K',
|
||||
};
|
||||
|
||||
config.loadModule(
|
||||
'ace/ext/menu_tools/get_editor_keyboard_shortcuts',
|
||||
(m) => {
|
||||
@@ -129,6 +140,11 @@ class Editor extends React.Component<EditorProps> {
|
||||
bindKey: { win: 'F2', mac: 'F2' },
|
||||
exec: (editor) => onCheck(editor.getValue()),
|
||||
},
|
||||
{
|
||||
name: 'toggleDocs',
|
||||
bindKey: { win: 'Ctrl-D', mac: 'Cmd-D' },
|
||||
exec: () => onToggleDocs(),
|
||||
},
|
||||
{
|
||||
name: 'save',
|
||||
bindKey: { win: 'Ctrl-S', mac: 'Cmd-S' },
|
||||
@@ -202,6 +218,7 @@ class Editor extends React.Component<EditorProps> {
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
darkMode: state.settings.darkMode,
|
||||
showDocs: state.settings.showDocs,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
@@ -210,6 +227,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
// REVISIT: the options here might need to be changed - hopefully there is
|
||||
// one setting that works for all hub types for cases where we aren't connected.
|
||||
onCheck: (script) => dispatch(compile(script)),
|
||||
onToggleDocs: () => dispatch(toggleBoolean(SettingId.ShowDocs)),
|
||||
});
|
||||
|
||||
export default connect(
|
||||
|
||||
@@ -114,6 +114,7 @@ class OpenFileButton extends React.Component<Props> {
|
||||
? { pointerEvents: 'none' }
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent focus
|
||||
// onClick={this.props.onClick}
|
||||
// breaks Dropzone when this.props.onClick is undefined
|
||||
// so we have to do it the long way
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
Classes,
|
||||
Drawer,
|
||||
FormGroup,
|
||||
Hotkey,
|
||||
Hotkeys,
|
||||
HotkeysTarget,
|
||||
Position,
|
||||
Switch,
|
||||
Tooltip,
|
||||
@@ -16,8 +19,14 @@ import { WithI18nProps, withI18n } from '@shopify/react-i18n';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Action, Dispatch } from '../actions';
|
||||
import { closeSettings, openAboutDialog } from '../actions/app';
|
||||
import { setBoolean } from '../actions/settings';
|
||||
import {
|
||||
checkForUpdate,
|
||||
closeSettings,
|
||||
installPrompt,
|
||||
openAboutDialog,
|
||||
reload,
|
||||
} from '../actions/app';
|
||||
import { setBoolean, toggleBoolean } from '../actions/settings';
|
||||
import { RootState } from '../reducers';
|
||||
import { pseudolocalize } from '../settings/i18n';
|
||||
import {
|
||||
@@ -28,6 +37,7 @@ import {
|
||||
tooltipDelay,
|
||||
} from '../settings/ui';
|
||||
import { SettingId } from '../settings/user';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import ExternalLinkIcon from './ExternalLinkIcon';
|
||||
@@ -39,6 +49,11 @@ type StateProps = {
|
||||
showDocs: boolean;
|
||||
darkMode: boolean;
|
||||
flashCurrentProgram: boolean;
|
||||
serviceWorker: ServiceWorkerRegistration | null;
|
||||
checkingForUpdate: boolean;
|
||||
updateAvailable: boolean;
|
||||
beforeInstallPrompt: BeforeInstallPromptEvent | null;
|
||||
promptingInstall: boolean;
|
||||
};
|
||||
|
||||
type DispatchProps = {
|
||||
@@ -47,23 +62,36 @@ type DispatchProps = {
|
||||
onDarkModeChanged: (checked: boolean) => void;
|
||||
onFlashCurrentProgramChanged: (checked: boolean) => void;
|
||||
onAbout: () => void;
|
||||
onToggleDocs: () => void;
|
||||
onCheckForUpdate: (registration: ServiceWorkerRegistration) => void;
|
||||
onReload: (registration: ServiceWorkerRegistration) => void;
|
||||
onInstallPrompt: (event: BeforeInstallPromptEvent) => void;
|
||||
};
|
||||
|
||||
type SettingsProps = StateProps & DispatchProps & WithI18nProps;
|
||||
|
||||
@HotkeysTarget
|
||||
class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
render(): JSX.Element {
|
||||
const {
|
||||
i18n,
|
||||
open,
|
||||
onClose,
|
||||
showDocs,
|
||||
onShowDocsChanged,
|
||||
darkMode,
|
||||
onDarkModeChanged,
|
||||
serviceWorker,
|
||||
flashCurrentProgram,
|
||||
checkingForUpdate,
|
||||
updateAvailable,
|
||||
beforeInstallPrompt,
|
||||
promptingInstall,
|
||||
onClose,
|
||||
onShowDocsChanged,
|
||||
onDarkModeChanged,
|
||||
onFlashCurrentProgramChanged,
|
||||
onAbout,
|
||||
onCheckForUpdate,
|
||||
onReload,
|
||||
onInstallPrompt: onInstall,
|
||||
i18n,
|
||||
} = this.props;
|
||||
return (
|
||||
<Drawer
|
||||
@@ -195,6 +223,47 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
|
||||
<ExternalLinkIcon />
|
||||
</AnchorButton>
|
||||
<AboutDialog />
|
||||
</ButtonGroup>
|
||||
</FormGroup>
|
||||
<FormGroup label={i18n.translate(SettingsStringId.AppTitle)}>
|
||||
<ButtonGroup
|
||||
minimal={true}
|
||||
vertical={true}
|
||||
alignText="left"
|
||||
>
|
||||
{beforeInstallPrompt && (
|
||||
<Button
|
||||
icon="add"
|
||||
onClick={() => onInstall(beforeInstallPrompt)}
|
||||
loading={promptingInstall}
|
||||
>
|
||||
{i18n.translate(
|
||||
SettingsStringId.AppInstallLabel,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{serviceWorker && !updateAvailable && (
|
||||
<Button
|
||||
icon="refresh"
|
||||
onClick={() => onCheckForUpdate(serviceWorker)}
|
||||
loading={checkingForUpdate}
|
||||
>
|
||||
{i18n.translate(
|
||||
SettingsStringId.AppCheckForUpdateLabel,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{serviceWorker && updateAvailable && (
|
||||
<Button
|
||||
icon="refresh"
|
||||
onClick={() => onReload(serviceWorker)}
|
||||
>
|
||||
{i18n.translate(
|
||||
SettingsStringId.AppRestartLabel,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
icon="info-sign"
|
||||
onClick={() => {
|
||||
@@ -202,16 +271,17 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
{i18n.translate(SettingsStringId.HelpAboutLabel)}
|
||||
{i18n.translate(SettingsStringId.AppAboutLabel)}
|
||||
</Button>
|
||||
<AboutDialog />
|
||||
</ButtonGroup>
|
||||
</FormGroup>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<FormGroup label="Developer">
|
||||
<Switch
|
||||
checked={i18n.pseudolocalize !== false}
|
||||
onClick={() => pseudolocalize(!i18n.pseudolocalize)}
|
||||
onChange={() =>
|
||||
pseudolocalize(!i18n.pseudolocalize)
|
||||
}
|
||||
label="Pseudolocalize"
|
||||
/>
|
||||
</FormGroup>
|
||||
@@ -221,6 +291,22 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
renderHotkeys(): JSX.Element {
|
||||
return (
|
||||
<Hotkeys>
|
||||
<Hotkey
|
||||
combo="mod+d"
|
||||
label={this.props.i18n.translate(
|
||||
SettingsStringId.AppearanceDocumentationTooltip,
|
||||
)}
|
||||
global={true}
|
||||
preventDefault={true}
|
||||
onKeyDown={() => this.props.onToggleDocs()}
|
||||
/>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
@@ -228,6 +314,11 @@ const mapStateToProps = (state: RootState): StateProps => ({
|
||||
showDocs: state.settings.showDocs,
|
||||
darkMode: state.settings.darkMode,
|
||||
flashCurrentProgram: state.settings.flashCurrentProgram,
|
||||
serviceWorker: state.app.serviceWorker,
|
||||
checkingForUpdate: state.app.checkingForUpdate,
|
||||
updateAvailable: state.app.updateAvailable,
|
||||
beforeInstallPrompt: state.app.beforeInstallPrompt,
|
||||
promptingInstall: state.app.promptingInstall,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
@@ -239,6 +330,10 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
onFlashCurrentProgramChanged: (checked): Action =>
|
||||
dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)),
|
||||
onAbout: (): Action => dispatch(openAboutDialog()),
|
||||
onToggleDocs: (): Action => dispatch(toggleBoolean(SettingId.ShowDocs)),
|
||||
onCheckForUpdate: (registration) => dispatch(checkForUpdate(registration)),
|
||||
onReload: (registration) => dispatch(reload(registration)),
|
||||
onInstallPrompt: (event) => dispatch(installPrompt(event)),
|
||||
});
|
||||
|
||||
export default connect(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
@import '../variables.scss';
|
||||
|
||||
.#{$ns}-dark .splitter-layout > .layout-splitter {
|
||||
.#{$ns}-dark .splitter-layout > .layout-splitter {
|
||||
// make layout splitter match app color scheme
|
||||
background-color: $pt-dark-app-background-color;
|
||||
}
|
||||
@@ -22,3 +22,9 @@
|
||||
.terminal-padding {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
// hide the docs and resize separator
|
||||
|
||||
div.pb-hide-docs > :not(.layout-pane-primary) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
// add "BETA" watermark
|
||||
|
||||
.pb-beta .ace_scroller::after {
|
||||
content: "";
|
||||
background: url("./images/beta.svg");
|
||||
content: '';
|
||||
background: url('./images/beta.svg');
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
pointer-events: none
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"copyErrorMessage": "Copy Error Message",
|
||||
"reportBug": "Report Bug",
|
||||
"app": {
|
||||
"noUpdateFound": "{appName} is already up to date."
|
||||
},
|
||||
"ble": {
|
||||
"gattPermission": "The web browser did not give permission to use Bluetooth Low Energy",
|
||||
"gattServiceNotFound": "Connected to hub but failed to get {serviceName} service. Try removing the \"{hubName}\" device in your OS Bluetooth settings, then try again.",
|
||||
"noWebBluetooth": "This web browser does not support Web Bluetooth or it is not enabled.",
|
||||
"noBluetooth": "No Bluetooth adapter could be found. Bluetooth won't work.",
|
||||
"unexpectedError": "Unexpected error while trying to connect: {errorMessage}"
|
||||
},
|
||||
"editor": {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
// Notification translation keys.
|
||||
|
||||
export enum MessageId {
|
||||
AppNoUpdateFound = 'app.noUpdateFound',
|
||||
CopyErrorMessage = 'copyErrorMessage',
|
||||
ReportBug = 'reportBug',
|
||||
BleUnexpectedError = 'ble.unexpectedError',
|
||||
BleGattPermission = 'ble.gattPermission',
|
||||
BleGattServiceNotFound = 'ble.gattServiceNotFound',
|
||||
BleNoWebBluetooth = 'ble.noWebBluetooth',
|
||||
BleNoBluetooth = 'ble.noBluetooth',
|
||||
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
|
||||
FlashFirmwareBleError = 'flashFirmware.bleError',
|
||||
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
},
|
||||
"bugs": {
|
||||
"label": "Bug Reports"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"title": "App",
|
||||
"install": {
|
||||
"label": "Install as App"
|
||||
},
|
||||
"checkForUpdate": {
|
||||
"label": "Check for Update"
|
||||
},
|
||||
"restart": {
|
||||
"label": "Restart to Install Update"
|
||||
},
|
||||
"about": {
|
||||
"label": "About"
|
||||
|
||||
@@ -19,5 +19,9 @@ export enum SettingsStringId {
|
||||
HelpSupportLabel = 'settings.help.support.label',
|
||||
HelpChatLabel = 'settings.help.chat.label',
|
||||
HelpBugsLabel = 'settings.help.bugs.label',
|
||||
HelpAboutLabel = 'settings.help.about.label',
|
||||
AppTitle = 'settings.app.title',
|
||||
AppInstallLabel = 'settings.app.install.label',
|
||||
AppCheckForUpdateLabel = 'settings.app.checkForUpdate.label',
|
||||
AppRestartLabel = 'settings.app.restart.label',
|
||||
AppAboutLabel = 'settings.app.about.label',
|
||||
}
|
||||
|
||||
+4
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
@import './variables.scss';
|
||||
@import '~normalize.css';
|
||||
@import '@blueprintjs/core/src/blueprint.scss';
|
||||
@import '~@blueprintjs/core/src/blueprint.scss';
|
||||
|
||||
:root {
|
||||
--mobile-pad: 0px;
|
||||
@@ -19,7 +19,9 @@ body {
|
||||
|
||||
.h-body {
|
||||
// height makes everything fit without scrolling
|
||||
height: calc(100vh - #{$pt-navbar-height} - #{$pb-status-bar-height} - var(--mobile-pad)) !important;
|
||||
height: calc(
|
||||
100vh - #{$pt-navbar-height} - #{$pb-status-bar-height} - var(--mobile-pad)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
|
||||
@@ -44,8 +44,20 @@ store.subscribe(() => {
|
||||
if (newDarkMode !== oldDarkMode) {
|
||||
if (newDarkMode) {
|
||||
document.body.classList.add(Classes.DARK);
|
||||
for (const frame of document.getElementsByTagName('iframe')) {
|
||||
console.log('dark');
|
||||
frame.contentWindow?.document.documentElement.classList.add(
|
||||
Classes.DARK,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
document.body.classList.remove(Classes.DARK);
|
||||
for (const frame of document.getElementsByTagName('iframe')) {
|
||||
console.log('light');
|
||||
frame.contentWindow?.document.documentElement.classList.remove(
|
||||
Classes.DARK,
|
||||
);
|
||||
}
|
||||
}
|
||||
oldDarkMode = newDarkMode;
|
||||
}
|
||||
|
||||
+80
-1
@@ -6,11 +6,18 @@
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import { Action } from '../actions';
|
||||
import { AppActionType } from '../actions/app';
|
||||
import { ServiceWorkerActionType } from '../actions/service-worker';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
|
||||
export interface AppState {
|
||||
readonly showSettings: boolean;
|
||||
readonly showAboutDialog: boolean;
|
||||
readonly showLicenseDialog: boolean;
|
||||
readonly serviceWorker: ServiceWorkerRegistration | null;
|
||||
readonly checkingForUpdate: boolean;
|
||||
readonly updateAvailable: boolean;
|
||||
readonly beforeInstallPrompt: BeforeInstallPromptEvent;
|
||||
readonly promptingInstall: boolean;
|
||||
}
|
||||
|
||||
const showSettings: Reducer<boolean, Action> = (state = false, action) => {
|
||||
@@ -46,4 +53,76 @@ const showLicenseDialog: Reducer<boolean, Action> = (state = false, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default combineReducers({ showSettings, showAboutDialog, showLicenseDialog });
|
||||
const serviceWorker: Reducer<ServiceWorkerRegistration | null, Action> = (
|
||||
state = null,
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case ServiceWorkerActionType.DidSucceed:
|
||||
return action.registration;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const checkingForUpdate: Reducer<boolean, Action> = (state = false, action) => {
|
||||
switch (action.type) {
|
||||
case AppActionType.CheckForUpdate:
|
||||
return true;
|
||||
case AppActionType.DidCheckForUpdate:
|
||||
if (!action.updateFound) {
|
||||
return false;
|
||||
}
|
||||
// otherwise we wait for service worker to download everything
|
||||
return state;
|
||||
case ServiceWorkerActionType.DidUpdate:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAvailable: Reducer<boolean, Action> = (state = false, action) => {
|
||||
switch (action.type) {
|
||||
case ServiceWorkerActionType.DidUpdate:
|
||||
return true;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const beforeInstallPrompt: Reducer<BeforeInstallPromptEvent | null, Action> = (
|
||||
state = null,
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case AppActionType.DidBeforeInstallPrompt:
|
||||
return action.event;
|
||||
case AppActionType.DidInstall:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const promptingInstall: Reducer<boolean, Action> = (state = false, action) => {
|
||||
switch (action.type) {
|
||||
case AppActionType.InstallPrompt:
|
||||
return true;
|
||||
case AppActionType.DidInstallPrompt:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
showSettings,
|
||||
showAboutDialog,
|
||||
showLicenseDialog,
|
||||
serviceWorker,
|
||||
checkingForUpdate,
|
||||
updateAvailable,
|
||||
beforeInstallPrompt,
|
||||
promptingInstall,
|
||||
});
|
||||
|
||||
+74
-10
@@ -2,9 +2,40 @@
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { AsyncSaga, delay } from '../../test';
|
||||
import { reload } from '../actions/app';
|
||||
import {
|
||||
checkForUpdate,
|
||||
didBeforeInstallPrompt,
|
||||
didCheckForUpdate,
|
||||
didInstallPrompt,
|
||||
installPrompt,
|
||||
reload,
|
||||
} from '../actions/app';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import app from './app';
|
||||
|
||||
test('monitorAppInstalled', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
window.dispatchEvent(new Event('appinstalled'));
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toStrictEqual(didInstallPrompt());
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('monitorBeforeInstallPrompt', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
|
||||
window.dispatchEvent(event);
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toStrictEqual(didBeforeInstallPrompt(event));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('reload', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
@@ -13,11 +44,6 @@ test('reload', async () => {
|
||||
unregister: jest.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error: navigator.serviceWorker is not implemented in JSDOM
|
||||
navigator.serviceWorker = {
|
||||
getRegistrations: jest.fn().mockResolvedValue([registration]),
|
||||
};
|
||||
|
||||
// @ts-expect-error: JSDOM implementation of location.reload() causes error
|
||||
delete window.location;
|
||||
// @ts-expect-error: JSDOM implementation of location.reload() causes error
|
||||
@@ -25,13 +51,51 @@ test('reload', async () => {
|
||||
reload: jest.fn(),
|
||||
};
|
||||
|
||||
saga.put(reload());
|
||||
|
||||
// yield to allow generators to complete
|
||||
await delay(0);
|
||||
saga.put(reload(registration as ServiceWorkerRegistration));
|
||||
|
||||
expect(registration.unregister).toHaveBeenCalled();
|
||||
expect(location.reload).toHaveBeenCalled();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('checkForUpdates', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
// mock registration as if service worker was register on app startup
|
||||
const registration: Partial<ServiceWorkerRegistration> = {
|
||||
update: jest.fn(),
|
||||
installing: null,
|
||||
};
|
||||
|
||||
saga.put(checkForUpdate(registration as ServiceWorkerRegistration));
|
||||
|
||||
// yield to allow generators to complete
|
||||
await delay(0);
|
||||
|
||||
expect(registration.update).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toStrictEqual(didCheckForUpdate(false));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('installPrompt', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
// mock registration as if service worker was register on app startup
|
||||
const event: Partial<BeforeInstallPromptEvent> = {
|
||||
prompt: jest.fn(),
|
||||
userChoice: Promise.resolve({ outcome: 'accepted', platform: 'web' }),
|
||||
};
|
||||
|
||||
saga.put(installPrompt(event as BeforeInstallPromptEvent));
|
||||
|
||||
expect(event.prompt).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toStrictEqual(didInstallPrompt());
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
+63
-8
@@ -1,21 +1,76 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { call, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { AppActionType } from '../actions/app';
|
||||
import { eventChannel } from 'redux-saga';
|
||||
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import {
|
||||
AppActionType,
|
||||
AppCheckForUpdatesAction,
|
||||
AppInstallPromptAction,
|
||||
AppReloadAction,
|
||||
didBeforeInstallPrompt,
|
||||
didCheckForUpdate,
|
||||
didInstall,
|
||||
didInstallPrompt,
|
||||
} from '../actions/app';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
|
||||
function* reload(): Generator {
|
||||
// unregister the service worker so that when the page reloads, it uses
|
||||
// the new version
|
||||
const registrations = yield* call(() => navigator.serviceWorker.getRegistrations());
|
||||
function* monitorAppInstalled(): Generator {
|
||||
const chan = eventChannel<Event>((emit) => {
|
||||
const listener = (e: Event) => {
|
||||
emit(e);
|
||||
};
|
||||
// chromium-only event
|
||||
window.addEventListener('appinstalled', listener);
|
||||
// istanbul ignore next: currently we don't ever stop monitoring
|
||||
return () => window.removeEventListener('appinstalled', listener);
|
||||
});
|
||||
|
||||
for (const r of registrations) {
|
||||
yield* call(() => r.unregister());
|
||||
while (true) {
|
||||
yield* take(chan);
|
||||
yield* put(didInstall());
|
||||
}
|
||||
}
|
||||
|
||||
function* monitorBeforeInstallPrompt(): Generator {
|
||||
const chan = eventChannel<BeforeInstallPromptEvent>((emit) => {
|
||||
const listener = (e: BeforeInstallPromptEvent) => {
|
||||
emit(e);
|
||||
};
|
||||
// @ts-expect-error: chromium-only event
|
||||
window.addEventListener('beforeinstallprompt', listener);
|
||||
// istanbul ignore next: currently we don't ever stop monitoring
|
||||
// @ts-expect-error: chromium-only event
|
||||
return () => window.removeEventListener('beforeinstallprompt', listener);
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const event = yield* take(chan);
|
||||
yield* put(didBeforeInstallPrompt(event));
|
||||
}
|
||||
}
|
||||
|
||||
function* reload(action: AppReloadAction): Generator {
|
||||
yield* call(() => action.registration.unregister());
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function* checkForUpdate(action: AppCheckForUpdatesAction): Generator {
|
||||
yield* call(() => action.registration.update());
|
||||
const updateFound = action.registration.installing !== null;
|
||||
yield* put(didCheckForUpdate(updateFound));
|
||||
}
|
||||
|
||||
function* installPrompt(action: AppInstallPromptAction): Generator {
|
||||
yield* call(() => action.event.prompt());
|
||||
yield* call(() => action.event.userChoice);
|
||||
yield* put(didInstallPrompt());
|
||||
}
|
||||
|
||||
export default function* app(): Generator {
|
||||
yield* fork(monitorAppInstalled);
|
||||
yield* fork(monitorBeforeInstallPrompt);
|
||||
yield* takeEvery(AppActionType.Reload, reload);
|
||||
yield* takeEvery(AppActionType.CheckForUpdate, checkForUpdate);
|
||||
yield* takeEvery(AppActionType.InstallPrompt, installPrompt);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ function* connect(_action: BleDeviceConnectAction): Generator {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: check navigator.bluetooth.getAvailability()
|
||||
const available = yield* call(() => navigator.bluetooth.getAvailability());
|
||||
if (!available) {
|
||||
yield* put(didFailToConnect({ reason: Reason.NoBluetooth }));
|
||||
return;
|
||||
}
|
||||
|
||||
let device: BluetoothDevice;
|
||||
try {
|
||||
|
||||
@@ -45,7 +45,11 @@ function* connect(_action: BootloaderConnectionAction): Generator {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: check navigator.bluetooth.getAvailability()
|
||||
const available = yield* call(() => navigator.bluetooth.getAvailability());
|
||||
if (!available) {
|
||||
yield* put(didFailToConnect(Reason.NoBluetooth));
|
||||
return;
|
||||
}
|
||||
|
||||
let device: BluetoothDevice;
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IToaster } from '@blueprintjs/core';
|
||||
import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { Action } from '../actions';
|
||||
import { didCheckForUpdate } from '../actions/app';
|
||||
import {
|
||||
BleDeviceFailToConnectReasonType,
|
||||
didFailToConnect as bleDidFailToConnect,
|
||||
@@ -28,6 +29,7 @@ import notification from './notification';
|
||||
|
||||
test.each([
|
||||
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth }),
|
||||
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoBluetooth }),
|
||||
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoGatt }),
|
||||
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoService }),
|
||||
bleDidFailToConnect({
|
||||
@@ -38,6 +40,7 @@ test.each([
|
||||
message: 'test',
|
||||
}),
|
||||
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth),
|
||||
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoBluetooth),
|
||||
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound),
|
||||
storageChanged('test'),
|
||||
didFailToCompile(['reason']),
|
||||
@@ -69,6 +72,7 @@ test.each([
|
||||
didFailToFinish(FailToFinishReasonType.FailedToCompile),
|
||||
didFailToFinish(FailToFinishReasonType.FirmwareSize),
|
||||
didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')),
|
||||
didCheckForUpdate(false),
|
||||
])('actions that should show notification: %o', async (action: Action) => {
|
||||
const getToasts = jest.fn().mockReturnValue([]);
|
||||
const show = jest.fn();
|
||||
@@ -98,6 +102,7 @@ test.each([
|
||||
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled),
|
||||
didFailToFinish(FailToFinishReasonType.FailedToConnect),
|
||||
didSucceed({} as ServiceWorkerRegistration),
|
||||
didCheckForUpdate(true),
|
||||
])('actions that should not show a notification: %o', async (action: Action) => {
|
||||
const getToasts = jest.fn().mockReturnValue([]);
|
||||
const show = jest.fn();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Replacements } from '@shopify/react-i18n';
|
||||
import React from 'react';
|
||||
import { channel } from 'redux-saga';
|
||||
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { reload } from '../actions/app';
|
||||
import { AppActionType, AppDidCheckForUpdateAction, reload } from '../actions/app';
|
||||
import {
|
||||
BleDeviceActionType,
|
||||
BleDeviceDidFailToConnectAction,
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
} from '../actions/lwp3-bootloader';
|
||||
import { MpyActionType, MpyDidFailToCompileAction } from '../actions/mpy';
|
||||
import { NotificationActionType, NotificationAddAction } from '../actions/notification';
|
||||
import { ServiceWorkerActionType } from '../actions/service-worker';
|
||||
import {
|
||||
ServiceWorkerAction,
|
||||
ServiceWorkerActionType,
|
||||
} from '../actions/service-worker';
|
||||
import Notification from '../components/Notification';
|
||||
import UnexpectedErrorNotification from '../components/UnexpectedErrorNotification';
|
||||
import { MessageId } from '../components/notification-i18n';
|
||||
@@ -171,6 +174,9 @@ function* showBleDeviceDidFailToConnectError(
|
||||
hubName: 'Pybricks Hub',
|
||||
});
|
||||
break;
|
||||
case BleDeviceFailToConnectReasonType.NoBluetooth:
|
||||
yield* showSingleton(Level.Error, MessageId.BleNoBluetooth);
|
||||
break;
|
||||
case BleDeviceFailToConnectReasonType.NoWebBluetooth:
|
||||
yield* showSingleton(
|
||||
Level.Error,
|
||||
@@ -207,6 +213,9 @@ function* showBootloaderDidFailToConnectError(
|
||||
),
|
||||
);
|
||||
break;
|
||||
case BootloaderConnectionFailureReason.NoBluetooth:
|
||||
yield* showSingleton(Level.Error, MessageId.BleNoBluetooth);
|
||||
break;
|
||||
case BootloaderConnectionFailureReason.Unknown:
|
||||
yield* showUnexpectedError(MessageId.BleUnexpectedError, action.err);
|
||||
break;
|
||||
@@ -317,7 +326,9 @@ function* addNotification(action: NotificationAddAction): Generator {
|
||||
});
|
||||
}
|
||||
|
||||
function* showServiceWorkerUpdate(): Generator {
|
||||
function* showServiceWorkerUpdate(
|
||||
updateAction: ServiceWorkerAction<ServiceWorkerActionType.DidUpdate>,
|
||||
): Generator {
|
||||
const ch = channel<React.MouseEvent<HTMLElement>>();
|
||||
const action = dispatchAction(
|
||||
MessageId.ServiceWorkerUpdateAction,
|
||||
@@ -337,7 +348,24 @@ function* showServiceWorkerUpdate(): Generator {
|
||||
|
||||
yield* take(ch);
|
||||
|
||||
yield* put(reload());
|
||||
yield* put(reload(updateAction.registration));
|
||||
}
|
||||
|
||||
function* showNoUpdateInfo(action: AppDidCheckForUpdateAction): Generator {
|
||||
if (action.updateFound) {
|
||||
// this will be handled by ServiceWorkerActionType.DidUpdate action
|
||||
return;
|
||||
}
|
||||
|
||||
const { toaster } = yield* getContext<NotificationContext>('notification');
|
||||
toaster.show({
|
||||
intent: mapIntent(Level.Info),
|
||||
icon: mapIcon(Level.Info),
|
||||
message: React.createElement(Notification, {
|
||||
messageId: MessageId.AppNoUpdateFound,
|
||||
replacements: { appName },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
@@ -355,4 +383,5 @@ export default function* (): Generator {
|
||||
yield* takeEvery(MpyActionType.DidFailToCompile, showCompilerError);
|
||||
yield* takeEvery(NotificationActionType.Add, addNotification);
|
||||
yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate);
|
||||
yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { didStart } from '../actions/app';
|
||||
import { didBooleanChange, didFailToSetBoolean, setBoolean } from '../actions/settings';
|
||||
import {
|
||||
didBooleanChange,
|
||||
didFailToSetBoolean,
|
||||
setBoolean,
|
||||
toggleBoolean,
|
||||
} from '../actions/settings';
|
||||
import { SettingId } from '../settings/user';
|
||||
import settings from './settings';
|
||||
|
||||
@@ -345,3 +350,24 @@ describe('storage monitor', () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
test('showDocs', async () => {
|
||||
const saga = new AsyncSaga(settings, { settings: { showDocs: false } });
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation((key, value) => {
|
||||
expect(key).toBe('setting.showDocs');
|
||||
expect(value).toBe('true');
|
||||
});
|
||||
|
||||
saga.put(toggleBoolean(SettingId.ShowDocs));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ import { AppActionType } from '../actions/app';
|
||||
import {
|
||||
SettingsActionType,
|
||||
SettingsSetBooleanAction,
|
||||
SettingsToggleBooleanAction,
|
||||
didBooleanChange,
|
||||
didFailToSetBoolean,
|
||||
setBoolean,
|
||||
} from '../actions/settings';
|
||||
import { RootState } from '../reducers';
|
||||
import { SettingId, getDefaultBooleanValue } from '../settings/user';
|
||||
@@ -97,8 +99,14 @@ function* storeSetting(action: SettingsSetBooleanAction): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
function* toggleSetting(action: SettingsToggleBooleanAction): Generator {
|
||||
const oldValue = yield* select((s: RootState) => s.settings[action.id]);
|
||||
yield* storeSetting(setBoolean(action.id, !oldValue));
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* fork(monitorLocalStorage);
|
||||
yield* takeEvery(AppActionType.DidStart, loadSettings);
|
||||
yield* takeEvery(SettingsActionType.SetBoolean, storeSetting);
|
||||
yield* takeEvery(SettingsActionType.ToggleBoolean, toggleSetting);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ function registerValidSW(swUrl: string, config?: Config): void {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
if (registration.active) {
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
// Chromium-only API
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
|
||||
export interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[];
|
||||
readonly userChoice: Promise<{
|
||||
outcome: 'accepted' | 'dismissed';
|
||||
platform: string;
|
||||
}>;
|
||||
prompt(): Promise<void>;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
@import '@blueprintjs/core/lib/scss/variables.scss';
|
||||
@import '~@blueprintjs/core/lib/scss/variables.scss';
|
||||
|
||||
// override variables here
|
||||
// See https://blueprintjs.com/docs/#core/variables
|
||||
|
||||
@@ -1462,10 +1462,10 @@
|
||||
dependencies:
|
||||
jszip "^3.5.0"
|
||||
|
||||
"@pybricks/ide-docs@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@pybricks/ide-docs/-/ide-docs-1.0.0.tgz#e93ff78c65730bbdd26b9deaf3110799d46de8e6"
|
||||
integrity sha512-pe52fHG3S2QIx7RLX8+a+5oQo5A/svR2bxJWWImwZ2unAbPYmZ2G/25zgtYb9JF4Fec2f9mLT2zRiGqUjiCjiw==
|
||||
"@pybricks/ide-docs@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@pybricks/ide-docs/-/ide-docs-1.1.0.tgz#15c46def964ad8e92805bc3746a1d418d145e5c4"
|
||||
integrity sha512-NwEPoAnMSvNShtzXSSuf+zOf3COPzXb0DQiD7RV6p3edCPvcgZn53JZvZscXdyudAruOMHZciw/jW8X4dJee2g==
|
||||
|
||||
"@pybricks/mpy-cross-v5@^2.0.0":
|
||||
version "2.0.0"
|
||||
|
||||
Reference in New Issue
Block a user