Merge pull request #1008 from pybricks/dlech

Pybricks tour
This commit is contained in:
David Lechner
2022-07-27 15:41:40 -05:00
committed by GitHub
43 changed files with 823 additions and 104 deletions
+1
View File
@@ -8,6 +8,7 @@
- Added better error message when no files to backup ([support#681]).
- Added multi-step firmware flashing dialog.
- Added support for flashing firmware via USB DFU.
- Added an interactive introductory tour of the app.
### Fixed
- Fixed deleting files that are not open in the editor.
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
<g id="chevron_right">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.71,7.29l-4-4C6.53,3.11,6.28,3,6,3C5.45,3,5,3.45,5,4 c0,0.28,0.11,0.53,0.29,0.71L8.59,8l-3.29,3.29C5.11,11.47,5,11.72,5,12c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l4-4 C10.89,8.53,11,8.28,11,8C11,7.72,10.89,7.47,10.71,7.29z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 672 B

-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
<g id="more_3_">
<circle cx="2" cy="8.03" r="2"/>
<circle cx="14" cy="8.03" r="2"/>
<circle cx="8" cy="8.03" r="2"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 566 B

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
<g id="small_minus_2_">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11,7H5C4.45,7,4,7.45,4,8c0,0.55,0.45,1,1,1h6c0.55,0,1-0.45,1-1 C12,7.45,11.55,7,11,7z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
<g id="small_tick_1_">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12,5c-0.28,0-0.53,0.11-0.71,0.29L7,9.59L4.71,7.29C4.53,7.11,4.28,7,4,7 C3.45,7,3,7.45,3,8c0,0.28,0.11,0.53,0.29,0.71l3,3C6.47,11.89,6.72,12,7,12s0.53-0.11,0.71-0.29l5-5C12.89,6.53,13,6.28,13,6 C13,5.45,12.55,5,12,5z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 658 B

-3
View File
@@ -1,3 +0,0 @@
Icons copied from: https://github.com/palantir/blueprint/tree/develop/resources/icons/16px
Needed to work around: https://github.com/palantir/blueprint/issues/4759
+1
View File
@@ -91,6 +91,7 @@
"react-dev-utils": "^12.0.1",
"react-dom": "^16.13.1",
"react-dropzone": "^14.2.2",
"react-joyride": "^2.5.0",
"react-monaco-editor": "^0.49.0",
"react-popper": "^2.3.0",
"react-redux": "^8.0.2",
+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>
);
};
+6
View File
@@ -38,6 +38,12 @@ export const pybricksBluetoothTroubleshootingUrl =
export const pybricksUsbDfuTroubleshootingUrl =
'https://github.com/pybricks/support/discussions/688';
export const pybricksUsbDfuWindowsDriverInstallUrl =
'https://github.com/pybricks/support/discussions/688#discussioncomment-3201466';
export const pybricksUsbLinuxUdevRulesUrl =
'https://github.com/pybricks/support/discussions/688#discussioncomment-3239099';
/** Pybricks copyright statement. */
export const pybricksCopyright = 'Copyright (c) 2020-2022 The Pybricks Authors';
+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,
+5 -10
View File
@@ -3,21 +3,16 @@
import { Radio, RadioGroup } from '@blueprintjs/core';
import React from 'react';
import { useHubPickerSelectedHub } from './hooks';
import { Hub } from '.';
type HubPickerProps = {
hubType: Hub;
onChange: (hubType: Hub) => void;
};
export const HubPicker: React.VoidFunctionComponent = () => {
const [selectedHub, setSelectedHub] = useHubPickerSelectedHub();
export const HubPicker: React.VoidFunctionComponent<HubPickerProps> = ({
hubType,
onChange,
}) => {
return (
<RadioGroup
selectedValue={hubType}
onChange={(e) => onChange(e.currentTarget.value as Hub)}
selectedValue={selectedHub}
onChange={(e) => setSelectedHub(e.currentTarget.value as Hub)}
>
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
<Radio value={Hub.City}>City Hub</Radio>
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { useLocalStorage } from 'usehooks-ts';
import { Hub } from '.';
/**
* Hook for {@link HubPicker} state backed by local storage.
* @returns Tuple of the current state and setter (like useState()).
*/
export function useHubPickerSelectedHub() {
return useLocalStorage('hubPicker.selectedHub', Hub.Move);
}
+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();
@@ -27,7 +27,7 @@ describe('accept', () => {
await user.click(button);
expect(dispatch).toHaveBeenCalledWith(
newFileWizardDidAccept('test', '.py', Hub.Technic),
newFileWizardDidAccept('test', '.py', Hub.Move),
);
});
@@ -42,7 +42,7 @@ describe('accept', () => {
);
expect(dispatch).toHaveBeenCalledWith(
newFileWizardDidAccept('test', '.py', Hub.Technic),
newFileWizardDidAccept('test', '.py', Hub.Move),
);
});
});
+3 -6
View File
@@ -5,8 +5,8 @@ import { Button, Classes, Dialog, FormGroup } from '@blueprintjs/core';
import React, { useCallback, useRef, useState } from 'react';
import { useId } from 'react-aria';
import { useDispatch } from 'react-redux';
import { Hub } from '../../components/hubPicker';
import { HubPicker } from '../../components/hubPicker/HubPicker';
import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
@@ -18,9 +18,6 @@ import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup';
import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions';
import { useI18n } from './i18n';
// This should be set to the most commonly used hub.
const defaultHub = Hub.Technic;
const NewFileWizard: React.VoidFunctionComponent = () => {
const i18n = useI18n();
const dispatch = useDispatch();
@@ -33,8 +30,8 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
pythonFileExtension,
files.map((f) => f.path),
);
const [hubType, setHubType] = useState(defaultHub);
const [hubType] = useHubPickerSelectedHub();
const fileNameInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = useCallback<React.FormEventHandler>(
@@ -70,7 +67,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
onChange={setFileName}
/>
<FormGroup label={i18n.translate('smartHub.label')}>
<HubPicker hubType={hubType} onChange={setHubType} />
<HubPicker />
</FormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Intent, ProgressBar } from '@blueprintjs/core';
import React from 'react';
import { CreateToast } from '../../i18nToaster';
import { useI18n } from './i18n';
type FlashProgressProps = {
action: 'erase' | 'flash';
progress: number | undefined;
};
const FlashProgress: React.VoidFunctionComponent<FlashProgressProps> = ({
action,
progress,
}) => {
const i18n = useI18n();
return (
<>
{action === 'erase' && (
<p>
{i18n.translate('flashProgress.erasing', {
percent: progress ? i18n.formatPercentage(progress) : '',
})}
</p>
)}
{action === 'flash' && (
<p>
{i18n.translate('flashProgress.flashing', {
percent: progress ? i18n.formatPercentage(progress) : '',
})}
</p>
)}
<p>
<ProgressBar value={progress} />
</p>
</>
);
};
export const flashProgress: CreateToast<FlashProgressProps> = (onAction, props) => {
return {
message: <FlashProgress {...props} />,
icon: 'download',
intent: Intent.PRIMARY,
// close one second after progress is complete
timeout: (props.progress ?? 0) < 1 ? 0 : 1000,
onDismiss: () => onAction('dismiss'),
};
};
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Intent } from '@blueprintjs/core';
import React from 'react';
import { CreateToast } from '../../i18nToaster';
import { useI18n } from './i18n';
const ReleaseButton: React.VoidFunctionComponent = () => {
const i18n = useI18n();
return <p>{i18n.translate('releaseButton.message')}</p>;
};
export const releaseButton: CreateToast = (onAction) => {
return {
message: <ReleaseButton />,
icon: 'info-sign',
intent: Intent.PRIMARY,
onDismiss: () => onAction('dismiss'),
};
};
+2
View File
@@ -5,10 +5,12 @@ import { firmwareMismatch } from './FirmwareMismatch';
import { noDfuHub } from './NoDfuHub';
import { noDfuInterface } from './NoDfuInterface';
import { noWebUsb } from './NoWebUsb';
import { releaseButton } from './ReleaseButton';
export default {
firmwareMismatch,
noDfuHub,
noDfuInterface,
noWebUsb,
releaseButton,
};
+7
View File
@@ -17,5 +17,12 @@
},
"firmwareMismatch": {
"message": "Cannot flash firmware. The firmware file is for a different kind of hub."
},
"flashProgress": {
"erasing": "Erasing internal flash memory: {percent}",
"flashing": "Writing new firmware: {percent}"
},
"releaseButton": {
"message": "You may release the power button on the hub now."
}
}
@@ -4,6 +4,7 @@
import './installPybricksDialog.scss';
import {
Button,
Callout,
Checkbox,
Classes,
ControlGroup,
@@ -24,7 +25,11 @@ import { Select2 } from '@blueprintjs/select';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { appName } from '../../app/constants';
import {
appName,
pybricksUsbDfuWindowsDriverInstallUrl,
pybricksUsbLinuxUdevRulesUrl,
} from '../../app/constants';
import HelpButton from '../../components/HelpButton';
import {
Hub,
@@ -34,9 +39,12 @@ import {
hubHasUSB,
} from '../../components/hubPicker';
import { HubPicker } from '../../components/hubPicker/HubPicker';
import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
import { FileMetadata } from '../../fileStorage';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import { useSelector } from '../../reducers';
import ExternalLinkIcon from '../../utils/ExternalLinkIcon';
import { isLinux, isWindows } from '../../utils/os';
import {
firmwareInstallPybricksDialogAccept,
firmwareInstallPybricksDialogCancel,
@@ -50,21 +58,13 @@ const dialogBody = classNames(
'pb-firmware-installPybricksDialog-body',
);
type SelectHubPanelProps = {
hubType: Hub;
onChange: (hubType: Hub) => void;
};
const SelectHubPanel: React.VoidFunctionComponent<SelectHubPanelProps> = ({
hubType,
onChange,
}) => {
const SelectHubPanel: React.VoidFunctionComponent = () => {
const i18n = useI18n();
return (
<div className={dialogBody}>
<p>{i18n.translate('selectHubPanel.message')}</p>
<HubPicker hubType={hubType} onChange={onChange} />
<HubPicker />
<Popover2
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
placement="right-end"
@@ -348,6 +348,37 @@ const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps>
return (
<div className={dialogBody}>
{hubHasUSB(hubType) && isLinux() && (
<p>
<Callout intent={Intent.WARNING} icon="warning-sign">
{i18n.translate('bootloaderPanel.warning.linux')}{' '}
<a
href={pybricksUsbLinuxUdevRulesUrl}
target="_blank"
rel="noreferrer"
>
{i18n.translate('bootloaderPanel.warning.learnMore')}
</a>
<ExternalLinkIcon />
</Callout>
</p>
)}
{hubHasUSB(hubType) && isWindows() && (
<p>
<Callout intent={Intent.WARNING} icon="warning-sign">
{i18n.translate('bootloaderPanel.warning.windows')}{' '}
<a
href={pybricksUsbDfuWindowsDriverInstallUrl}
target="_blank"
rel="noreferrer"
>
{i18n.translate('bootloaderPanel.warning.learnMore')}
</a>
<ExternalLinkIcon />
</Callout>
</p>
)}
<p>{i18n.translate('bootloaderPanel.instruction1')}</p>
<ol>
{hubHasUSB(hubType) && (
@@ -398,16 +429,14 @@ const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps>
);
};
const defaultHubType = Hub.Technic;
export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog);
const dispatch = useDispatch();
const [hubType, setHubType] = useState(defaultHubType);
const [hubName, setHubName] = useState('');
const [includeProgram, setIncludeProgram] = useState(false);
const [selectedIncludeFile, setSelectedIncludeFile] = useState<FileMetadata>();
const [licenseAccepted, setLicenseAccepted] = useState(false);
const [hubType] = useHubPickerSelectedHub();
const { data } = useFirmware(hubType);
const i18n = useI18n();
@@ -432,7 +461,7 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
<DialogStep
id="hub"
title={i18n.translate('selectHubPanel.title')}
panel={<SelectHubPanel hubType={hubType} onChange={setHubType} />}
panel={<SelectHubPanel />}
nextButtonProps={{ text: i18n.translate('nextButton.label') }}
/>
<DialogStep
@@ -5,7 +5,7 @@
.pb-firmware-installPybricksDialog {
&-body {
min-height: bp.$pt-grid-size * 25;
min-height: bp.$pt-grid-size * 35;
}
&-license {
@@ -17,11 +17,11 @@
&-text {
flex-grow: 1;
min-height: 0;
max-height: bp.$pt-grid-size * 20;
max-height: bp.$pt-grid-size * 30;
overflow: auto;
& .#{bp.$ns}-non-ideal-state {
min-height: bp.$pt-grid-size * 20;
min-height: bp.$pt-grid-size * 30;
}
}
}
@@ -55,6 +55,11 @@
},
"bootloaderPanel": {
"title": "Place hub in bootloader mode",
"warning": {
"linux": "If you have never used Pybricks with USB on Linux, you will need to configure udev rules to allow permission before you can flash the hub firmware.",
"windows": " If you have never used Pybricks with USB on Windows, you may need to manually install a USB driver before you can flash the hub firmware.",
"learnMore": "Learn more."
},
"instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:",
"button": {
"bluetooth": "Bluetooth button",
@@ -87,6 +92,6 @@
"label": "Next"
},
"flashFirmwareButton": {
"label": "Flash Firmware"
"label": "Connect and flash"
}
}
+45 -1
View File
@@ -1,13 +1,16 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
import { IToaster } from '@blueprintjs/core';
import {
FirmwareMetadata,
FirmwareReaderError,
FirmwareReaderErrorCode,
} from '@pybricks/firmware';
import { mock } from 'jest-mock-extended';
import JSZip from 'jszip';
import { AsyncSaga } from '../../test';
import { alertsShowAlert } from '../alerts/actions';
import {
BootloaderConnectionFailureReason,
checksumRequest,
@@ -78,6 +81,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -130,6 +134,9 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -224,6 +231,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -273,6 +281,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -340,6 +349,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -403,6 +413,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -469,6 +480,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -528,6 +540,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -593,6 +606,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -675,6 +689,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -740,6 +755,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -790,6 +806,9 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -835,6 +854,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -885,6 +905,9 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -939,6 +962,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -989,6 +1013,9 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -1083,6 +1110,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1133,6 +1161,9 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -1225,6 +1256,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1283,6 +1315,9 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
@@ -1374,6 +1409,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1421,6 +1457,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1467,6 +1504,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1527,6 +1565,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1588,6 +1627,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1652,6 +1692,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1741,6 +1782,7 @@ describe('flashFirmware', () => {
const saga = new AsyncSaga(flashFirmware, {
nextMessageId: createCountFunc(),
toaster: mock<IToaster>(),
});
// saga is triggered by this action
@@ -1791,12 +1833,14 @@ describe('flashFirmware', () => {
// erase first
action = await saga.take();
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
action = await saga.take();
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
saga.put(didRequest(1));
saga.put(eraseResponse(Result.OK));
// then write the new firmware
const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8;
+58 -4
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { IToaster } from '@blueprintjs/core';
import {
FirmwareReader,
FirmwareReaderError,
@@ -75,6 +76,7 @@ import {
firmwareInstallPybricks,
flashFirmware,
} from './actions';
import { flashProgress } from './alerts/FlashProgress';
import {
firmwareInstallPybricksDialogAccept,
firmwareInstallPybricksDialogCancel,
@@ -92,6 +94,10 @@ const firmwareZipMap = new Map<HubType, string>([
* parent task).
*/
function* disconnectAndCancel(): SagaGenerator<void> {
const toaster = yield* getContext<IToaster>('toaster');
toaster.dismiss('firmware.ble.progress');
const connection = yield* select((s: RootState) => s.bootloader.connection);
if (connection === BootloaderConnectionState.Connected) {
@@ -326,6 +332,8 @@ function* loadFirmware(
* @param action The action that triggered this saga.
*/
function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generator {
const toaster = yield* getContext<IToaster>('toaster');
try {
let firmware: Uint8Array | undefined = undefined;
let deviceId: HubType | undefined = undefined;
@@ -416,6 +424,16 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
yield* put(didStart());
toaster.show(
flashProgress(() => undefined, {
action: 'erase',
progress: undefined,
}),
'firmware.ble.progress',
);
yield* put(alertsShowAlert('firmware', 'releaseButton'));
const eraseAction = yield* put(
eraseRequest(nextMessageId(), deviceId === HubType.CityHub),
);
@@ -467,6 +485,14 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
yield* put(didProgress(offset / firmware.length));
toaster.show(
flashProgress(() => undefined, {
action: 'flash',
progress: offset / firmware.length,
}),
'firmware.ble.progress',
);
// we don't want to request checksum if this is the last packet since
// the bootloader will send a response to the program request already.
offset += maxDataSize;
@@ -540,6 +566,14 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
yield* put(didProgress(1));
toaster.show(
flashProgress(() => undefined, {
action: 'flash',
progress: 1,
}),
'firmware.ble.progress',
);
// this will cause the remote device to disconnect and reboot
const rebootAction = yield* put(rebootRequest(nextMessageId()));
yield* waitForDidRequest(rebootAction.id);
@@ -615,12 +649,10 @@ function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Gen
// forceInterfacesName is needed to get the flash layout map
{ forceInterfacesName: true },
{
// NB: info and progress are never called in dfu v0.1.5
info: console.debug,
warning: console.warn,
progress: (progress, total) => {
// TODO: bind to eventChannel and dispatch progress actions
console.log(progress, total);
},
progress: console.debug,
},
);
@@ -656,6 +688,28 @@ function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Gen
dfu.dfuseStartAddress = dfuFirmwareStartAddress;
const writeProc = dfu.write(1024, firmware, true);
const toaster = yield* getContext<IToaster>('toaster');
writeProc.events.on('erase/process', (sent, total) => {
toaster.show(
flashProgress(() => undefined, {
action: 'erase',
progress: sent / total,
}),
'firmware.dfu.progress',
);
});
writeProc.events.on('write/process', (sent, total) => {
toaster.show(
flashProgress(() => undefined, {
action: 'flash',
progress: sent / total,
}),
'firmware.dfu.progress',
);
});
writeProc.events.on('error', console.error);
// REVISIT: we could possibly race the 'write/end' and 'error' events
+2
View File
@@ -52,6 +52,8 @@ const store = configureStore({
// contain ArrayBuffer or DataView
'data',
'firmwareZip',
// Error is not serializable
'error',
],
},
})
+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."
}
}
}
+92 -1
View File
@@ -1802,6 +1802,13 @@ __metadata:
languageName: node
linkType: hard
"@gilbarbara/deep-equal@npm:^0.1.1":
version: 0.1.1
resolution: "@gilbarbara/deep-equal@npm:0.1.1"
checksum: d904761847320587cbb2a76310acc3344d241201e4e6521e9f88b2f4eb7d8a13aed2081fca1cad625ef78390651e5bbfe914ff5d6925a9f40cf305676a9e1f20
languageName: node
linkType: hard
"@humanwhocodes/config-array@npm:^0.9.2":
version: 0.9.5
resolution: "@humanwhocodes/config-array@npm:0.9.5"
@@ -2410,6 +2417,7 @@ __metadata:
react-dev-utils: ^12.0.1
react-dom: ^16.13.1
react-dropzone: ^14.2.2
react-joyride: ^2.5.0
react-monaco-editor: ^0.49.0
react-popper: ^2.3.0
react-redux: ^8.0.2
@@ -8000,6 +8008,13 @@ __metadata:
languageName: node
linkType: hard
"exenv@npm:^1.2.2":
version: 1.2.2
resolution: "exenv@npm:1.2.2"
checksum: a894f3b60ab8419e0b6eec99c690a009c8276b4c90655ccaf7d28faba2de3a6b93b3d92210f9dc5efd36058d44f04098f6bbccef99859151104bfd49939904dc
languageName: node
linkType: hard
"exit@npm:^0.1.2":
version: 0.1.2
resolution: "exit@npm:0.1.2"
@@ -9271,6 +9286,13 @@ __metadata:
languageName: node
linkType: hard
"is-lite@npm:^0.8.2":
version: 0.8.2
resolution: "is-lite@npm:0.8.2"
checksum: 0ee62cb238c2a044f58d1cd139fb0b48026c407ec8625ee6572b417f164e17ec937f0a0785f466e320749a796c316a3b78dcb4b520f7ddd4b9de38ad5a23d70f
languageName: node
linkType: hard
"is-module@npm:^1.0.0":
version: 1.0.0
resolution: "is-module@npm:1.0.0"
@@ -11478,7 +11500,7 @@ __metadata:
languageName: node
linkType: hard
"popper.js@npm:^1.14.4, popper.js@npm:^1.16.1":
"popper.js@npm:^1.14.4, popper.js@npm:^1.16.0, popper.js@npm:^1.16.1":
version: 1.16.1
resolution: "popper.js@npm:1.16.1"
checksum: c56ae5001ec50a77ee297a8061a0221d99d25c7348d2e6bcd3e45a0d0f32a1fd81bca29d46cb0d4bdf13efb77685bd6a0ce93f9eb3c608311a461f945fffedbe
@@ -12699,6 +12721,24 @@ __metadata:
languageName: node
linkType: hard
"react-floater@npm:^0.7.5":
version: 0.7.5
resolution: "react-floater@npm:0.7.5"
dependencies:
deepmerge: ^4.2.2
exenv: ^1.2.2
is-lite: ^0.8.2
popper.js: ^1.16.0
react-proptype-conditional-require: ^1.0.4
tree-changes: ^0.9.1
peerDependencies:
prop-types: ^15.7.2
react: 15 - 18
react-dom: 15 - 18
checksum: 72d0ccd15d93e08885e9d5ff59be757df198fa48e796b3ce48138e8ba0f393df4b246c18e4887ad62214607f75041366b34c0d4f0edc7e70e774eb236c377698
languageName: node
linkType: hard
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
@@ -12720,6 +12760,26 @@ __metadata:
languageName: node
linkType: hard
"react-joyride@npm:^2.5.0":
version: 2.5.0
resolution: "react-joyride@npm:2.5.0"
dependencies:
deepmerge: ^4.2.2
exenv: ^1.2.2
is-lite: ^0.8.2
react-floater: ^0.7.5
react-is: ^16.13.1
scroll: ^3.0.1
scrollparent: ^2.0.1
tree-changes: ^0.9.1
peerDependencies:
prop-types: ^15.0.0
react: 15 - 18
react-dom: 15 - 18
checksum: 2d15d1b6a6e6e830af0e594b53b5318dd91a3b57f183157404375d1760a36b4f91acf89c69e73fed7ba5d9d0c13bdc23b2ea426a64259635321a4aaa020aae9d
languageName: node
linkType: hard
"react-monaco-editor@npm:^0.49.0":
version: 0.49.0
resolution: "react-monaco-editor@npm:0.49.0"
@@ -12764,6 +12824,13 @@ __metadata:
languageName: node
linkType: hard
"react-proptype-conditional-require@npm:^1.0.4":
version: 1.0.4
resolution: "react-proptype-conditional-require@npm:1.0.4"
checksum: 78f82d15b2c77c14fd8fbcbbed279850df3a856984aacd519ee6c2162e034b114b8ac47c00157b84ef7c98c0711d933a0177d9d54555629cf381f54341bb0e8f
languageName: node
linkType: hard
"react-redux@npm:^8.0.2":
version: 8.0.2
resolution: "react-redux@npm:8.0.2"
@@ -13385,6 +13452,20 @@ __metadata:
languageName: node
linkType: hard
"scroll@npm:^3.0.1":
version: 3.0.1
resolution: "scroll@npm:3.0.1"
checksum: e6b045347adace30035073882e6ef2af7e1c81dd611faf3a578ca8cd0d1a3a9da54932dd97ed6fd99c9573351c758fa50e6d8ed4afb5bd3a33794b6c48d25922
languageName: node
linkType: hard
"scrollparent@npm:^2.0.1":
version: 2.0.1
resolution: "scrollparent@npm:2.0.1"
checksum: 9ff2b29c1233431ebd0910e5f8db4a058836ee0292d1bb18042fbb4d2175c3ed73fa28a71a96074ac6848173185238ad1139bdefc3863be4808df981175cb807
languageName: node
linkType: hard
"select-hose@npm:^2.0.0":
version: 2.0.0
resolution: "select-hose@npm:2.0.0"
@@ -14415,6 +14496,16 @@ __metadata:
languageName: node
linkType: hard
"tree-changes@npm:^0.9.1":
version: 0.9.1
resolution: "tree-changes@npm:0.9.1"
dependencies:
"@gilbarbara/deep-equal": ^0.1.1
is-lite: ^0.8.2
checksum: 6749f0213dbbfda592326250cdbbc9bb0f03c6c22e042e7089b1c4d6ab2fc1aa5cde0e02d31d5d7a32c901301146a01ed0c9b90a94007798681aa6af76bd6428
languageName: node
linkType: hard
"tryer@npm:^1.0.1":
version: 1.0.1
resolution: "tryer@npm:1.0.1"