diff --git a/src/actions/app.ts b/src/actions/app.ts new file mode 100644 index 00000000..227b1c51 --- /dev/null +++ b/src/actions/app.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: actions/app.ts +// Actions for the app in general. + +import { Action } from 'redux'; + +/** App action types. */ +export enum AppActionType { + /** The app has just ben started. */ + Startup = 'app.action.startup', + /** Toggle documentation visibility. */ + ToggleDocs = 'app.action.toggleDocs', +} + +/** Action that indicates the app has just started. */ +export type AppStartupAction = Action; + +/** Creates an action that indicates the app has just started. */ +export function startup(): AppStartupAction { + return { type: AppActionType.Startup }; +} + +/** Action to toggle documentation visibility. */ +export type AppToggleDocsAction = Action; + +/** Creates an action to toggle documentation visibility. */ +export function toggleDocs(): AppToggleDocsAction { + return { type: AppActionType.ToggleDocs }; +} + +/** common type for all app actions. */ +export type AppAction = AppStartupAction | AppToggleDocsAction; diff --git a/src/actions/index.ts b/src/actions/index.ts index 465b27df..c8ac0a27 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -2,6 +2,7 @@ // Copyright (c) 2020 The Pybricks Authors import { Dispatch as ReduxDispatch } from 'redux'; +import { AppAction } from './app'; import { BLEAction, BLEConnectAction } from './ble'; import { BleUartAction } from './ble-uart'; import { EditorAction } from './editor'; @@ -22,6 +23,7 @@ import { TerminalDataAction } from './terminal'; * Common type for all actions. */ export type Action = + | AppAction | BLEAction | BLEConnectAction | BleUartAction diff --git a/src/components/App.tsx b/src/components/App.tsx index 4bfa788e..3c0c90bc 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,8 +1,10 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import React, { EffectCallback, useEffect, useState } from 'react'; +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; import SplitterLayout from 'react-splitter-layout'; +import { RootState } from '../reducers'; import Editor from './Editor'; import StatusBar from './StatusBar'; import Terminal from './Terminal'; @@ -10,28 +12,8 @@ import Toolbar from './Toolbar'; import 'react-splitter-layout/lib/index.css'; -function useShowDocs(): boolean { - function getShowDocs(): boolean { - return window.innerWidth >= 1024; - } - - const [showDocs, setShowDocs] = useState(getShowDocs); - - useEffect((): ReturnType => { - function handleResize(): void { - setShowDocs(getShowDocs()); - } - - window.addEventListener('resize', handleResize); - - return (): void => window.removeEventListener('resize', handleResize); - }, []); // Empty array ensures that effect is only run on mount and unmount - - return showDocs; -} - function App(): JSX.Element { - const showDocs = useShowDocs(); + const showDocs = useSelector((s: RootState): boolean => s.app.showDocs); const [dragging, setDragging] = useState(false); return ( diff --git a/src/components/DocsButton.tsx b/src/components/DocsButton.tsx new file mode 100644 index 00000000..4c254726 --- /dev/null +++ b/src/components/DocsButton.tsx @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: components/DocsButton.ts +// Toolbar button for toggling documentation. + +import { connect } from 'react-redux'; +import { Action, Dispatch } from '../actions'; +import { toggleDocs } from '../actions/app'; +import ActionButton, { ActionButtonProps } from './ActionButton'; +import { TooltipId } from './button'; +import docsIcon from './images/run.svg'; // FIXME: need proper icon + +type StateProps = {}; +type DispatchProps = Pick; +type OwnProps = Pick; + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onAction: (): Action => dispatch(toggleDocs()), +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnProps, +): ActionButtonProps => ({ + tooltip: TooltipId.Docs, + icon: docsIcon, + ...ownProps, + ...stateProps, + ...dispatchProps, +}); + +export default connect(undefined, mapDispatchToProps, mergeProps)(ActionButton); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index fd1b8666..1a00c6c6 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { ButtonGroup, Navbar } from '@blueprintjs/core'; +import { Alignment, ButtonGroup, Navbar } from '@blueprintjs/core'; import React from 'react'; import BluetoothButton from './BluetoothButton'; +import DocsButton from './DocsButton'; import FlashButton from './FlashButton'; import OpenButton from './OpenButton'; import ReplButton from './ReplButton'; @@ -36,6 +37,11 @@ class Toolbar extends React.Component { + + + + + ); } diff --git a/src/components/button.en.json b/src/components/button.en.json index 9cb23f21..b339c710 100644 --- a/src/components/button.en.json +++ b/src/components/button.en.json @@ -8,5 +8,6 @@ "connect": { "tooltip": "Connect using Bluetooth" }, "disconnect": { "tooltip": "Disconnect Bluetooth" } }, - "flash": { "tooltip": "Flash hub firmware" } + "flash": { "tooltip": "Flash hub firmware" }, + "docs": { "tooltip": "Show/hide documentation" } } diff --git a/src/components/button.ts b/src/components/button.ts index 8cb8a3b3..bea206e8 100644 --- a/src/components/button.ts +++ b/src/components/button.ts @@ -10,4 +10,5 @@ export enum TooltipId { Flash = 'flash.tooltip', BluetoothConnect = 'bluetooth.connect.tooltip', BluetoothDisconnect = 'bluetooth.disconnect.tooltip', + Docs = 'docs.tooltip', } diff --git a/src/reducers/app.ts b/src/reducers/app.ts new file mode 100644 index 00000000..6b70b2b2 --- /dev/null +++ b/src/reducers/app.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: reducers/app.ts +// Manages state the app in general. + +import { Reducer, combineReducers } from 'redux'; +import { Action } from '../actions'; +import { AppActionType } from '../actions/app'; + +const showDocs: Reducer = (state = false, action) => { + switch (action.type) { + case AppActionType.ToggleDocs: + return !state; + default: + return state; + } +}; + +export interface AppState { + readonly showDocs: boolean; +} + +export default combineReducers({ showDocs }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 03430050..832c8a9a 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,6 +2,7 @@ // Copyright (c) 2020 The Pybricks Authors import { combineReducers } from 'redux'; +import app, { AppState } from './app'; import ble, { BleState } from './ble'; import bootloader, { BootloaderState } from './bootloader'; import editor, { EditorState } from './editor'; @@ -14,6 +15,7 @@ import terminal, { TerminalState } from './terminal'; * Root state for redux store. */ export interface RootState { + readonly app: AppState; readonly bootloader: BootloaderState; readonly ble: BleState; readonly editor: EditorState; @@ -24,6 +26,7 @@ export interface RootState { } export default combineReducers({ + app, bootloader, ble, editor, diff --git a/src/sagas/app.test.ts b/src/sagas/app.test.ts new file mode 100644 index 00000000..0030133c --- /dev/null +++ b/src/sagas/app.test.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: sagas/app.test.ts +// Tests for app sagas. + +import { AsyncSaga } from '../../test'; +import { AppActionType, startup, toggleDocs } from '../actions/app'; +import app from './app'; + +afterAll(() => { + jest.restoreAllMocks(); +}); + +describe('startup', () => { + test('with large screen', async () => { + const saga = new AsyncSaga(app); + + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockReturnValue(null); + innerWidth = 1024; + + saga.put(startup()); + + // toggles documentation to be visible + const toggleDocsAction = await saga.take(); + expect(toggleDocsAction.type).toBe(AppActionType.ToggleDocs); + + await saga.end(); + }); + + test('with small screen', async () => { + const saga = new AsyncSaga(app); + + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockReturnValue(null); + innerWidth = 800; + + saga.put(startup()); + + // does nothing + + await saga.end(); + }); + + test('with stored value "true"', async () => { + const saga = new AsyncSaga(app); + + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockReturnValue('true'); + innerWidth = 800; + + saga.put(startup()); + + // toggles documentation to be visible + const toggleDocsAction = await saga.take(); + expect(toggleDocsAction.type).toBe(AppActionType.ToggleDocs); + + await saga.end(); + }); + + test('with stored value "false"', async () => { + const saga = new AsyncSaga(app); + + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockReturnValue('false'); + innerWidth = 1024; + + saga.put(startup()); + + // does nothing + + await saga.end(); + }); +}); + +describe('storeDocsState', () => { + test('showing', async () => { + const saga = new AsyncSaga(app); + + const mockSetItem = jest + .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') + .mockImplementation((_key, value) => expect(value).toBe('true')); + saga.setState({ app: { showDocs: true } }); + saga.put(toggleDocs()); + expect(mockSetItem).toHaveBeenCalled(); + + await saga.end(); + }); + + test('hidden', async () => { + const saga = new AsyncSaga(app); + + const mockSetItem = jest + .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') + .mockImplementation((_key, value) => expect(value).toBe('false')); + saga.setState({ app: { showDocs: false } }); + saga.put(toggleDocs()); + expect(mockSetItem).toHaveBeenCalled(); + + await saga.end(); + }); +}); diff --git a/src/sagas/app.ts b/src/sagas/app.ts new file mode 100644 index 00000000..6b2e3e3c --- /dev/null +++ b/src/sagas/app.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors +// File: sagas/app.ts +// Manages the application lifecycle. + +import { put, select, takeEvery } from 'redux-saga/effects'; +import { + AppActionType, + AppStartupAction, + AppToggleDocsAction, + toggleDocs, +} from '../actions/app'; +import { RootState } from '../reducers'; + +function* handleStartup(_action: AppStartupAction): Generator { + const showDocs = localStorage.getItem('showDocs'); + if (showDocs === null ? window.innerWidth >= 1024 : showDocs === 'true') { + yield put(toggleDocs()); + } +} + +function* storeDocsState(_action: AppToggleDocsAction): Generator { + const showDocs = (yield select((s: RootState) => s.app.showDocs)) as boolean; + localStorage.setItem('showDocs', String(showDocs)); +} + +export default function* (): Generator { + yield takeEvery(AppActionType.Startup, handleStartup); + yield takeEvery(AppActionType.ToggleDocs, storeDocsState); +} diff --git a/src/sagas/index.ts b/src/sagas/index.ts index efc8649e..aa142fbe 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { all } from 'redux-saga/effects'; +import { all, put } from 'redux-saga/effects'; +import { startup } from '../actions/app'; +import app from './app'; import bleUart from './ble-uart'; import editor from './editor'; import errorLog from './error-log'; @@ -14,6 +16,7 @@ import terminal from './terminal'; /* istanbul ignore next */ export default function* (): Generator { yield all([ + app(), bleUart(), bootloader(), editor(), @@ -22,5 +25,6 @@ export default function* (): Generator { hub(), mpy(), terminal(), + put(startup()), ]); }