tour: add new tour component

This commit is contained in:
David Lechner
2022-07-27 15:19:36 -05:00
parent 0414e08e4f
commit 8bece7d957
23 changed files with 552 additions and 22 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
import { cleanup } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../test';
import Activities, { Activity } from './Activities';
import Activities from './Activities';
import { Activity } from './hooks';
afterEach(() => {
cleanup();
+4 -16
View File
@@ -4,32 +4,18 @@
import './activities.scss';
import { Icon, Tab, Tabs } from '@blueprintjs/core';
import React, { useCallback, useEffect, useRef } from 'react';
import { useLocalStorage } from 'usehooks-ts';
import Explorer from '../explorer/Explorer';
import Settings from '../settings/Settings';
import { Activity, useActivitiesSelectedActivity } from './hooks';
import { useI18n } from './i18n';
/** Indicates the selected activity. */
export enum Activity {
/** No activity is selected. */
None = 'activity.none',
/** The explorer activity is selected. */
Explorer = 'activity.explorer',
/** The settings activity is selected. */
Settings = 'activity.settings',
}
/**
* React component that acts as a tab control to select activities.
*/
const Activities: React.VoidFunctionComponent = () => {
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
const i18n = useI18n();
const [selectedActivity, setSelectedActivity] = useLocalStorage(
'activities.selectedActivity',
Activity.Explorer,
);
const handleAction = useCallback(
(newActivity: Activity) => {
// if activity is already selected, select none
@@ -110,6 +96,7 @@ const Activities: React.VoidFunctionComponent = () => {
ref={tabsRef}
>
<Tab
itemID="pb-activities-explorer-tab"
aria-label={i18n.translate('explorer')}
className="pb-activities-tablist-tab"
id={Activity.Explorer}
@@ -125,6 +112,7 @@ const Activities: React.VoidFunctionComponent = () => {
onMouseDown={(e) => e.stopPropagation()}
/>
<Tab
itemID="pb-activities-settings-tab"
aria-label={i18n.translate('settings')}
className="pb-activities-tablist-tab"
id={Activity.Settings}
+22
View File
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { useLocalStorage } from 'usehooks-ts';
/** Indicates the selected activity. */
export enum Activity {
/** No activity is selected. */
None = 'activity.none',
/** The explorer activity is selected. */
Explorer = 'activity.explorer',
/** The settings activity is selected. */
Settings = 'activity.settings',
}
/**
* React hook for getting and setting the selected activity in the activity panel.
* @returns a tuple of the current state and the setter function (like useState()).
*/
export function useActivitiesSelectedActivity() {
return useLocalStorage('activities.selectedActivity', Activity.Explorer);
}
+2
View File
@@ -14,6 +14,7 @@ import { useSettingIsShowDocsEnabled } from '../settings/hooks';
import StatusBar from '../status-bar/StatusBar';
import Terminal from '../terminal/Terminal';
import Toolbar from '../toolbar/Toolbar';
import Tour from '../tour/Tour';
import { isMacOS } from '../utils/os';
const Docs: React.VFC = () => {
@@ -196,6 +197,7 @@ const App: React.VFC = () => {
</div>
</div>
<StatusBar />
<Tour />
</div>
);
};
+4
View File
@@ -8,6 +8,8 @@ import React, { useRef } from 'react';
import { FocusRing, useButton } from 'react-aria';
type ButtonProps = {
/** Optional DOM ID for the button. */
id?: string;
/** The label for the button. */
label: string;
/** If true, the label will not be visible (will use aria-label instead). */
@@ -28,6 +30,7 @@ type ButtonProps = {
/** Similar to Blueprint.js button with better accessibility. */
export const Button: React.VoidFunctionComponent<ButtonProps> = ({
id,
label,
hideLabel,
description,
@@ -59,6 +62,7 @@ export const Button: React.VoidFunctionComponent<ButtonProps> = ({
{/* useButton() breaks the native browser :focus-visible :-/ */}
<FocusRing focusRingClass="pb-focus-ring">
<button
id={id}
className={classNames(Classes.BUTTON, 'pb-focus-managed', {
[Classes.ACTIVE]: isPressed,
[Classes.LOADING]: loading,
+4 -2
View File
@@ -156,10 +156,12 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
);
};
// matches ID in tour component
const archiveButtonId = 'pb-explorer-archive-button';
const newButtonId = 'pb-explorer-add-button';
const Header: React.VoidFunctionComponent = () => {
const archiveButtonId = useId();
const exportButtonId = useId();
const newButtonId = useId();
const dispatch = useDispatch();
const i18n = useI18n();
+2
View File
@@ -11,6 +11,7 @@ import fileStorage from './fileStorage/reducers';
import firmware from './firmware/reducers';
import hub from './hub/reducers';
import bootloader from './lwp3-bootloader/reducers';
import tour from './tour/reducers';
/**
* Root reducer for redux store.
@@ -24,6 +25,7 @@ export const rootReducer = combineReducers({
fileStorage,
firmware,
hub,
tour,
});
/**
+2
View File
@@ -96,6 +96,7 @@ const Settings: React.VoidFunctionComponent = () => {
</FormGroup>
<FormGroup label={i18n.translate('firmware.title')}>
<Button
id="pb-settings-flash-pybricks-button"
minimal={true}
icon="download"
label={i18n.translate('firmware.flashPybricksButton.label')}
@@ -103,6 +104,7 @@ const Settings: React.VoidFunctionComponent = () => {
/>
<InstallPybricksDialog />
<Button
id="pb-settings-flash-official-button"
minimal={true}
icon="download"
label={i18n.translate('firmware.flashLegoButton.label')}
+9 -2
View File
@@ -9,13 +9,17 @@ import BluetoothButton from './buttons/bluetooth/BluetoothButton';
import ReplButton from './buttons/repl/ReplButton';
import RunButton from './buttons/run/RunButton';
import StopButton from './buttons/stop/StopButton';
import TourButton from './buttons/tour/TourButton';
import './toolbar.scss';
// matches ID in tour component
const bluetoothButtonId = 'pb-toolbar-bluetooth-button';
const runButtonId = 'pb-toolbar-run-button';
const tourButtonId = 'pb-toolbar-tour-button';
const Toolbar: React.VFC = () => {
const flashButtonId = useId();
const bluetoothButtonId = useId();
const runButtonId = useId();
const stopButtonId = useId();
const replButtonId = useId();
@@ -29,6 +33,9 @@ const Toolbar: React.VFC = () => {
<StopButton id={stopButtonId} />
<ReplButton id={replButtonId} />
</ButtonGroup>
<ButtonGroup className="pb-toolbar-group pb-align-right">
<TourButton id={tourButtonId} />
</ButtonGroup>
</UtilsToolbar>
);
};
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { cleanup } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../../../test';
import { HubRuntimeState } from '../../../hub/reducers';
import { tourStart } from '../../../tour/actions';
import TourButton from './TourButton';
afterEach(() => {
cleanup();
});
it('should dispatch action when clicked', async () => {
const [user, button, dispatch] = testRender(<TourButton id="test-tour-button" />, {
hub: { runtime: HubRuntimeState.Running },
});
await user.click(button.getByRole('button', { name: 'Tour Pybricks Code' }));
expect(dispatch).toHaveBeenCalledWith(tourStart());
});
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import React from 'react';
import { useDispatch } from 'react-redux';
import { appName } from '../../../app/constants';
import { tourStart } from '../../../tour/actions';
import ActionButton, { ActionButtonProps } from '../../ActionButton';
import { useI18n } from './i18n';
import icon from './icon.svg';
type TourButtonProps = Pick<ActionButtonProps, 'id'>;
const TourButton: React.VoidFunctionComponent<TourButtonProps> = ({ id }) => {
const i18n = useI18n();
const dispatch = useDispatch();
return (
<ActionButton
id={id}
label={i18n.translate('label', { appName })}
tooltip={i18n.translate('tooltip', { appName })}
icon={icon}
onAction={() => dispatch(tourStart())}
/>
);
};
export default TourButton;
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
import type { TypedI18n } from '../../../i18n';
import type translations from './translations/en.json';
export function useI18n(): TypedI18n<typeof translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
+1
View File
@@ -0,0 +1 @@
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -270.54)"><path d="m1.9234 276.13c-1.0655 0-1.9234 0.85655-1.9234 1.9213v10.588c0 1.0648 0.85787 1.9213 1.9234 1.9213h3.3755c1.0655 0 1.9234-0.85653 1.9234-1.9213v-0.47646h12.014v0.47646c0 1.0648 0.85787 1.9213 1.9234 1.9213h3.3755c1.0655 0 1.9234-0.85653 1.9234-1.9213v-10.588c0-1.0648-0.85786-1.9213-1.9234-1.9213h-3.3755c-5.4826 0.0358-11.132 0.0125-15.861 0zm0.49609 2.4123h21.644v9.617h-2.3978v-2.3916h-2.0691v-0.89193h-0.74311v0.89193h-1.664v-0.89193h-0.74001v0.89193h-1.664v-0.89193h-0.74001v0.89193h-1.664v-0.89193h-0.74311v0.89193h-1.664v-0.89193h-0.74104v0.89193h-1.664v-0.89193h-0.68316v0.89193h-2.0691v2.3916h-2.3978v-3.2835z" fill="#fcfcfc"/><ellipse cx="8.4499" cy="281.03" rx="1.7076" ry="1.7064" fill="#fff"/><ellipse cx="18.033" cy="281.03" rx="1.7076" ry="1.7064" fill="#fff"/></g></svg>

After

Width:  |  Height:  |  Size: 937 B

@@ -0,0 +1,4 @@
{
"label": "Tour {appName}",
"tooltip": "Take a quick tour of {appName}"
}
+255
View File
@@ -0,0 +1,255 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Icon } from '@blueprintjs/core';
import React, { useCallback, useMemo, useState } from 'react';
import Joyride, {
ACTIONS,
CallBackProps,
EVENTS,
Locale,
STATUS,
Step,
Styles,
} from 'react-joyride';
import { useDispatch } from 'react-redux';
import { useEffectOnce, useLocalStorage, useTernaryDarkMode } from 'usehooks-ts';
import { Activity, useActivitiesSelectedActivity } from '../activities/hooks';
import { appName } from '../app/constants';
import { useSelector } from '../reducers';
import { tourStart, tourStop } from './actions';
import { useI18n } from './i18n';
const WelcomeStep = React.memo(function WelcomeStep() {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('steps.welcome.message', { appName })}</p>
<p>
{i18n.translate('steps.welcome.action', {
next: <strong>{i18n.translate('next')}</strong>,
})}
</p>
</>
);
});
const AddFileStep = React.memo(function AddFileStep() {
const i18n = useI18n();
return (
<p>
{i18n.translate('steps.newFile.message', {
icon: <Icon icon="plus" style={{ verticalAlign: 'text-top' }} />,
})}
</p>
);
});
const BackupFilesStep = React.memo(function BackupFilesStep() {
const i18n = useI18n();
return (
<>
<p>{i18n.translate('steps.backupFiles.message')}</p>
<p>
{i18n.translate('steps.backupFiles.action', {
icon: <Icon icon="archive" style={{ verticalAlign: 'text-top' }} />,
})}
</p>
</>
);
});
const FlashPybricksFirmwareStep = React.memo(function FlashPybricksFirmwareStep() {
const i18n = useI18n();
return (
<p>
{i18n.translate('steps.flashPybricksFirmware.message', {
appName,
})}
</p>
);
});
const RestoreOfficialFirmwareStep = React.memo(function RestoreOfficialFirmwareStep() {
const i18n = useI18n();
return <p>{i18n.translate('steps.restoreOfficialFirmware.message')}</p>;
});
const ConnectToHubStep = React.memo(function ConnectToHubStep() {
const i18n = useI18n();
return <p>{i18n.translate('steps.connectToHub.message')}</p>;
});
const DownloadAndRunStep = React.memo(function DownloadAndRunStep() {
const i18n = useI18n();
return (
<p>
{i18n.translate('steps.downloadAndRun.message', {
icon: <Icon icon="play" style={{ verticalAlign: 'text-top' }} />,
})}
</p>
);
});
const Tour: React.VoidFunctionComponent = () => {
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
const [showOnStartup, setShowOnStartup] = useLocalStorage(
'tour.showOnStartup',
true,
);
const [stepIndex, setStepIndex] = useState(0);
const { isRunning } = useSelector((s) => s.tour);
const { isDarkMode } = useTernaryDarkMode();
const dispatch = useDispatch();
const i18n = useI18n();
const steps = useMemo<Step[]>(
() => [
{
target: '#pb-toolbar-tour-button',
content: <WelcomeStep />,
disableBeacon: true,
},
{
target:
selectedActivity === Activity.Explorer
? '#pb-explorer-add-button'
: '[itemId=pb-activities-explorer-tab]',
content: <AddFileStep />,
disableBeacon: selectedActivity === Activity.Explorer,
},
{
target:
selectedActivity === Activity.Explorer
? '#pb-explorer-archive-button'
: '[itemId=pb-activities-explorer-tab]',
content: <BackupFilesStep />,
disableBeacon: selectedActivity === Activity.Explorer,
},
{
target:
selectedActivity === Activity.Settings
? '#pb-settings-flash-pybricks-button'
: '[itemId=pb-activities-settings-tab]',
content: <FlashPybricksFirmwareStep />,
disableBeacon: selectedActivity === Activity.Settings,
},
{
target:
selectedActivity === Activity.Settings
? '#pb-settings-flash-official-button'
: '[itemId=pb-activities-settings-tab]',
content: <RestoreOfficialFirmwareStep />,
disableBeacon: selectedActivity === Activity.Settings,
},
{
target: '#pb-toolbar-bluetooth-button',
content: <ConnectToHubStep />,
disableBeacon: true,
},
{
target: '#pb-toolbar-run-button',
content: <DownloadAndRunStep />,
disableBeacon: true,
},
],
[selectedActivity, i18n],
);
const styles = useMemo<Styles>(
() => ({
// colors come from variables.scss
options: {
// $pybricks-blue
primaryColor: '#0088ce',
// $pt-dark-text-color / $pt-text-color
textColor: isDarkMode ? '#f6f7f9' : '#1c2127',
// $pt-dark-app-background-color / $pt-app-background-color
backgroundColor: isDarkMode ? '#252a31' : '#f6f7f9',
arrowColor: isDarkMode ? '#252a31' : '#f6f7f9',
},
}),
[isDarkMode],
);
const locale = useMemo<Locale>(
() => ({
back: i18n.translate('back'),
close: i18n.translate('close'),
last: i18n.translate('last'),
next: i18n.translate('next'),
open: i18n.translate('open'),
skip: i18n.translate('skip'),
}),
[i18n],
);
const callback = useCallback(
(event: CallBackProps) => {
if (event.action === ACTIONS.CLOSE || event.status === STATUS.FINISHED) {
dispatch(tourStop());
setStepIndex(0);
}
if (event.type === EVENTS.STEP_AFTER) {
const nextIndex = stepIndex + (event.action === ACTIONS.PREV ? -1 : 1);
const nextStep = steps.at(nextIndex);
// Some components may not be mounted when the next/back button
// is pressed. If this is the case, first target the tab, then
// request to activate that tab. When the tab panel is mounted,
// the target will automatically update.
if (typeof nextStep?.target === 'string') {
if (nextStep.target.startsWith('[itemId=pb-activities-explorer-')) {
setSelectedActivity(Activity.Explorer);
} else if (
nextStep.target.startsWith('[itemId=pb-activities-settings-')
) {
setSelectedActivity(Activity.Settings);
}
}
setStepIndex(nextIndex);
}
},
[
dispatch,
selectedActivity,
setSelectedActivity,
stepIndex,
setStepIndex,
steps,
],
);
// automatically show the tour on the first run only
useEffectOnce(() => {
if (showOnStartup) {
dispatch(tourStart());
setShowOnStartup(false);
}
});
return (
<Joyride
run={isRunning}
stepIndex={stepIndex}
continuous={true}
showProgress={true}
steps={steps}
styles={styles}
locale={locale}
callback={callback}
/>
);
};
export default Tour;
+4
View File
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
export { start as tourStart, stop as tourStop } from './redux/tour';
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
import type { TypedI18n } from '../i18n';
import type translations from './translations/en.json';
export function useI18n(): TypedI18n<typeof translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
+6
View File
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import tour from './redux/tour';
export default tour;
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createSlice } from '@reduxjs/toolkit';
type State = {
isRunning: boolean;
};
const initialState: State = {
isRunning: false,
};
const slice = createSlice({
name: 'tour',
initialState,
reducers: {
start(state) {
state.isRunning = true;
},
stop(state) {
state.isRunning = false;
},
},
});
export const { start, stop } = slice.actions;
export default slice.reducer;
+33
View File
@@ -0,0 +1,33 @@
{
"back": "Back",
"close": "Close",
"last": "Last",
"next": "Next",
"open": "Open the dialog",
"skip": "Skip",
"steps": {
"welcome": {
"message": "Welcome to {appName}.",
"action": "Click {next} to discover key features."
},
"newFile": {
"message": "Click the {icon} button to create a new program."
},
"backupFiles": {
"message": "Don't lose your work.",
"action": "Click the {icon} button to backup all of your programs."
},
"flashPybricksFirmware": {
"message": "{appName} requires a special firmware to be flashed to your hub in order to run the program on the hub."
},
"restoreOfficialFirmware": {
"message": "You can always restore the official LEGO® firmware after using the special Pybricks firmware."
},
"connectToHub": {
"message": "One you have flashed the Pybricks firmware, click the Bluetooth button to connect to the hub."
},
"downloadAndRun": {
"message": "After you are connected to the hub, click the {icon} button to download and run your program."
}
}
}