explorer: create new file wizard

This commit is contained in:
David Lechner
2022-03-10 02:22:29 -06:00
parent fd120bb5a6
commit 96f0dd16fd
12 changed files with 465 additions and 13 deletions
+34 -11
View File
@@ -253,42 +253,57 @@ export const language = <monaco.languages.IMonarchLanguage>{
},
};
function template(hubName: string, devices: string[]): string {
return `from pybricks.hubs import ${hubName}
from pybricks.pupdevices import ${devices.join(', ')}
/**
* Creates a new template for the given parameters.
*
* @param hubClassName The hub class name, e.g. `"MoveHub"`.
* @param deviceClassNames A list of device class names, e.g. `["Motor"]`.
* @returns The template.
*/
function createTemplate(hubClassName: string, deviceClassNames: string[]): string {
return `from pybricks.hubs import ${hubClassName}
from pybricks.pupdevices import ${deviceClassNames.join(', ')}
from pybricks.parameters import Button, Color, Direction, Port, Stop
from pybricks.robotics import DriveBase
from pybricks.tools import wait, StopWatch
hub = ${hubName}()
hub = ${hubClassName}()
`;
}
type HubLabel =
| 'movehub'
| 'cityhub'
| 'technichub'
| 'inventorhub'
| 'primehub'
| 'essentialhub';
const templateSnippets: Array<
Required<
Pick<monaco.languages.CompletionItem, 'label' | 'documentation' | 'insertText'>
> & { label: string }
> & { label: HubLabel }
> = [
{
label: 'technichub',
documentation: 'Template for Technic hub program.',
insertText: template('TechnicHub', ['Motor']),
insertText: createTemplate('TechnicHub', ['Motor']),
},
{
label: 'cityhub',
documentation: 'Template for City hub program.',
insertText: template('CityHub', ['DCMotor', 'Light']),
insertText: createTemplate('CityHub', ['DCMotor', 'Light']),
},
{
label: 'movehub',
documentation: 'Template for BOOST Move hub program.',
insertText: template('MoveHub', ['Motor', 'ColorDistanceSensor']),
insertText: createTemplate('MoveHub', ['Motor', 'ColorDistanceSensor']),
},
{
label: 'inventorhub',
documentation: 'Template for MINDSTORMS Robot Inventor hub program.',
insertText: template('InventorHub', [
insertText: createTemplate('InventorHub', [
'Motor',
'ColorSensor',
'UltrasonicSensor',
@@ -297,7 +312,7 @@ const templateSnippets: Array<
{
label: 'primehub',
documentation: 'Template for SPIKE Prime program.',
insertText: template('PrimeHub', [
insertText: createTemplate('PrimeHub', [
'Motor',
'ColorSensor',
'UltrasonicSensor',
@@ -307,7 +322,7 @@ const templateSnippets: Array<
{
label: 'essentialhub',
documentation: 'Template for SPIKE Essential program.',
insertText: template('EssentialHub', [
insertText: createTemplate('EssentialHub', [
'Motor',
'ColorSensor',
'ColorLightMatrix',
@@ -315,6 +330,14 @@ const templateSnippets: Array<
},
];
/**
* Gets the template text for a Pybricks MicroPython file.
* @param hub The hub label.
*/
export function getPybricksMicroPythonFileTemplate(hub: HubLabel): string | undefined {
return templateSnippets.find((t) => t.label === hub)?.insertText;
}
export const templateSnippetCompletions = <monaco.languages.CompletionItemProvider>{
provideCompletionItems: (model, position, _context, _token) => {
// templates snippets are only available on the first line
+17
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { getByLabelText, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
@@ -37,6 +38,22 @@ describe('archive button', () => {
});
});
describe('new file button', () => {
it('should show new file wizard', async () => {
const [explorer] = testRender(<Explorer />);
const button = explorer.getByTitle('Create a new file');
userEvent.click(button);
const dialog = explorer.getByRole('dialog');
expect(dialog).toBeVisible();
userEvent.click(getByLabelText(dialog, 'Close'));
await waitFor(() => expect(dialog).not.toBeVisible());
});
});
describe('list item', () => {
it('should show/hide buttons on hover', () => {
const [explorer] = testRender(<Explorer />, {
+7 -1
View File
@@ -20,6 +20,7 @@ import {
fileStorageExportFile,
} from '../fileStorage/actions';
import { useSelector } from '../reducers';
import NewFileWizard from './NewFileWizard';
import { ExplorerStringId } from './i18n';
import en from './i18n.en.json';
@@ -102,6 +103,7 @@ const FileActionButtonGroup = forwardRef<
FileActionButtonGroup.displayName = 'FileActionButtonGroup';
const Header: React.VFC = () => {
const [isNewFileWizardOpen, setIsNewFileWizardOpen] = useState(false);
const dispatch = useDispatch();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
@@ -125,7 +127,11 @@ const Header: React.VFC = () => {
<ActionButton
icon="plus"
toolTipId={ExplorerStringId.HeaderAddNewTooltip}
onClick={() => alert('not implemented')}
onClick={() => setIsNewFileWizardOpen(true)}
/>
<NewFileWizard
isOpen={isNewFileWizardOpen}
onClose={() => setIsNewFileWizardOpen(false)}
/>
</ButtonGroup>
</div>
+241
View File
@@ -0,0 +1,241 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import {
Button,
Classes,
Dialog,
FormGroup,
InputGroup,
Radio,
RadioGroup,
Tag,
} from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useSelector } from '../reducers';
import { FileExtension, Hub, explorerCreateNewFile } from './actions';
import { NewFileWizardStringId } from './i18n';
import en from './i18n.en.json';
// This should be set to the most commonly used hub.
const defaultHub = Hub.Technic;
/** File name validation results. */
enum FileNameValidationResult {
/** The file name is acceptable. */
IsOk,
/** The file name is an empty string. */
IsEmpty,
/** The file name contains spaces. */
HasSpaces,
/** The file name include the file file extension. */
HasFileExtension,
/** The first character is not a letter or underscore. */
HasInvalidFirstCharacter,
/** The file name has invalid characters. */
HasInvalidCharacters,
/** A file with the same name already exists. */
AlreadyExists,
}
/**
* Validates the file name according to a number of criteria.
*
* @param fileName The file name (without extension).
* @param extension The file extension (include ".").
* @param existingFiles List of existing files.
* @returns The result of the validation.
*/
function validateFileName(
fileName: string,
extension: string,
existingFiles: ReadonlyArray<string>,
): FileNameValidationResult {
if (existingFiles.includes(`${fileName}${extension}`)) {
return FileNameValidationResult.AlreadyExists;
}
if (fileName.length === 0) {
return FileNameValidationResult.IsEmpty;
}
if (fileName.match(/\s/)) {
return FileNameValidationResult.HasSpaces;
}
if (fileName.endsWith(extension)) {
return FileNameValidationResult.HasFileExtension;
}
if (!fileName.match(/^[a-zA-Z_]/)) {
return FileNameValidationResult.HasInvalidFirstCharacter;
}
if (!fileName.match(/^[a-zA-Z0-9_-]+$/)) {
return FileNameValidationResult.HasInvalidCharacters;
}
return FileNameValidationResult.IsOk;
}
type FileNameHelpTextProps = {
validation: FileNameValidationResult;
};
/**
* Component that maps FileNameValidationResult to help message to display to user.
*/
const FileNameHelpText: React.VoidFunctionComponent<FileNameHelpTextProps> = (
props,
) => {
const [i18n] = useI18n({ id: 'newFileWizard', translations: { en }, fallback: en });
switch (props.validation) {
case FileNameValidationResult.IsOk:
return <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextIsOk)}</>;
case FileNameValidationResult.IsEmpty:
return <>{i18n.translate(NewFileWizardStringId.FileNameHelpTextIsEmpty)}</>;
case FileNameValidationResult.HasSpaces:
return (
<>{i18n.translate(NewFileWizardStringId.FileNameHelpTextHasSpaces)}</>
);
case FileNameValidationResult.HasFileExtension:
return (
<>
{i18n.translate(
NewFileWizardStringId.FileNameHelpTextHasFileExtension,
)}
</>
);
case FileNameValidationResult.HasInvalidFirstCharacter:
return (
<>
{i18n.translate(
NewFileWizardStringId.FileNameHelpTextHasInvalidFirstCharacter,
{
letters: <code className={Classes.CODE}>az</code>,
underscore: <code className={Classes.CODE}>_</code>,
},
)}
</>
);
case FileNameValidationResult.HasInvalidCharacters:
return (
<>
{i18n.translate(
NewFileWizardStringId.FileNameHelpTextHasInvalidCharacters,
{
letters: <code className={Classes.CODE}>az</code>,
numbers: <code className={Classes.CODE}>09</code>,
dash: <code className={Classes.CODE}>-</code>,
underscore: <code className={Classes.CODE}>_</code>,
},
)}
</>
);
case FileNameValidationResult.AlreadyExists:
return (
<>
{i18n.translate(
NewFileWizardStringId.FileNameHelpTextAlreadyExists,
)}
</>
);
}
};
type NewFileWizardProps = {
readonly isOpen: boolean;
readonly onClose: () => void;
};
const NewFileWizard: React.VoidFunctionComponent<NewFileWizardProps> = (props) => {
const [i18n] = useI18n({ id: 'newFileWizard', translations: { en }, fallback: en });
const dispatch = useDispatch();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
const [fileName, setFileName] = useState('');
const [fileNameValidation, setFileNameValidation] = useState(
FileNameValidationResult.IsEmpty,
);
const [hubType, setHubType] = useState(defaultHub);
const fileNameInputRef = useRef<HTMLInputElement>(null);
const fileNameIntent =
fileNameValidation === FileNameValidationResult.IsOk ? 'none' : 'danger';
const handleFileNameChanged = (fileName: string) => {
setFileNameValidation(
validateFileName(fileName, FileExtension.Python, fileNames),
);
setFileName(fileName);
};
return (
<Dialog
icon="plus"
title={i18n.translate(NewFileWizardStringId.Title)}
isOpen={props.isOpen}
onOpening={() => handleFileNameChanged('')}
onOpened={() => fileNameInputRef.current?.focus()}
onClose={() => props.onClose()}
>
<div className={Classes.DIALOG_BODY}>
<FormGroup
label={i18n.translate(NewFileWizardStringId.FileNameLabel)}
intent={fileNameIntent}
subLabel={<FileNameHelpText validation={fileNameValidation} />}
>
<InputGroup
aria-label="File name"
value={fileName}
inputRef={fileNameInputRef}
intent={fileNameIntent}
rightElement={<Tag>{FileExtension.Python}</Tag>}
onChange={(e) => handleFileNameChanged(e.target.value)}
/>
</FormGroup>
<FormGroup label={i18n.translate(NewFileWizardStringId.SmartHubLabel)}>
<RadioGroup
selectedValue={hubType}
onChange={(e) => setHubType(e.currentTarget.value as Hub)}
>
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
<Radio value={Hub.City}>City Hub</Radio>
<Radio value={Hub.Technic}>Technic Hub</Radio>
<Radio value={Hub.Prime}>SPIKE Prime</Radio>
<Radio value={Hub.Essential}>SPIKE Essential</Radio>
<Radio value={Hub.Inventor}>MINDSTORMS Robot Inventor</Radio>
</RadioGroup>
</FormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
aria-label="Create"
intent="primary"
disabled={fileNameValidation !== FileNameValidationResult.IsOk}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
props.onClose();
dispatch(
explorerCreateNewFile(
fileName,
FileExtension.Python,
hubType,
),
);
}}
>
{i18n.translate(NewFileWizardStringId.ActionCreate)}
</Button>
</div>
</div>
</Dialog>
);
};
export default NewFileWizard;
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
import NewFileWizard from './NewFileWizard';
describe('create button', () => {
it('should close the dialog', async () => {
const onClose = jest.fn();
const [dialog] = testRender(<NewFileWizard isOpen={true} onClose={onClose} />);
const button = dialog.getByLabelText('Create');
// have to type a file name before Create button is enabled
userEvent.type(dialog.getByLabelText('File name'), 'test');
await waitFor(() => expect(button).not.toBeDisabled());
userEvent.click(button);
expect(onClose).toHaveBeenCalled();
});
});
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createAction } from '../actions';
/** Supported file extensions. */
export enum FileExtension {
/** Python (.py) */
Python = '.py',
}
/** Supported hub types. */
export enum Hub {
/** BOOST Move hub */
Move = 'movehub',
/** City hub */
City = 'cityhub',
/** Technic hub */
Technic = 'technichub',
/** MINDSTORMS Robot Inventor hub */
Inventor = 'inventorhub',
/** SPIKE Prime hub */
Prime = 'primehub',
/** SPIKE Essential hub */
Essential = 'essentialhub',
}
/**
* Action that requests to create a new file.
* @param fileName The requested new file name (without file extension).
* @param fileExtension The file extension (including leading ".").
* @param hub The type of hub this file is for.
*/
export const explorerCreateNewFile = createAction(
(fileName: string, fileExtension: FileExtension, hub: Hub) => ({
type: 'explorer.action.createNewFile',
fileName,
fileExtension,
hub,
}),
);
+21
View File
@@ -10,5 +10,26 @@
"exportTooltip": "Export {fileName}",
"renameTooltip": "Rename {fileName}"
}
},
"newFileWizard": {
"title": "Create a new file",
"fileName": {
"label": "File name",
"helpText": {
"isOk": "OK!",
"isEmpty": "File name cannot be empty.",
"hasSpaces": "File name cannot have spaces.",
"hasFileExtension": "The file extension will be added automatically.",
"hasInvalidFirstCharacter": "File name must start with a letter ({letters}) or underscore ({underscore}).",
"hasInvalidCharacters": "File name can only contain letters ({letters}), numbers ({numbers}), dashes ({dash}) and underscores ({underscore}).",
"alreadyExists": "A file with this name already exists."
}
},
"smartHub": {
"label": "Smart hub"
},
"action": {
"create": "Create"
}
}
}
+7 -1
View File
@@ -2,7 +2,7 @@
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../test';
import { ExplorerStringId } from './i18n';
import { ExplorerStringId, NewFileWizardStringId } from './i18n';
import en from './i18n.en.json';
describe('Ensure .json file has matches for ExplorerStringId', () => {
@@ -10,3 +10,9 @@ describe('Ensure .json file has matches for ExplorerStringId', () => {
expect(lookup(en, id)).toBeDefined();
});
});
describe('Ensure .json file has matches for NewFileWizardStringId', () => {
test.each(Object.values(NewFileWizardStringId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+14
View File
@@ -11,3 +11,17 @@ export enum ExplorerStringId {
TreeItemExportTooltip = 'explorer.treeItem.exportTooltip',
TreeItemRenameTooltip = 'explorer.treeItem.renameTooltip',
}
export enum NewFileWizardStringId {
Title = 'newFileWizard.title',
FileNameLabel = 'newFileWizard.fileName.label',
FileNameHelpTextIsOk = 'newFileWizard.fileName.helpText.isOk',
FileNameHelpTextIsEmpty = 'newFileWizard.fileName.helpText.isEmpty',
FileNameHelpTextHasSpaces = 'newFileWizard.fileName.helpText.hasSpaces',
FileNameHelpTextHasFileExtension = 'newFileWizard.fileName.helpText.hasFileExtension',
FileNameHelpTextHasInvalidFirstCharacter = 'newFileWizard.fileName.helpText.hasInvalidFirstCharacter',
FileNameHelpTextHasInvalidCharacters = 'newFileWizard.fileName.helpText.hasInvalidCharacters',
FileNameHelpTextAlreadyExists = 'newFileWizard.fileName.helpText.alreadyExists',
SmartHubLabel = 'newFileWizard.smartHub.label',
ActionCreate = 'newFileWizard.action.create',
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { AsyncSaga } from '../../test';
import { FileExtension, Hub, explorerCreateNewFile } from './actions';
import explorer from './sagas';
describe('handleExplorerCreateNewFile', () => {
it('should dispatch fileStorage action', async () => {
const saga = new AsyncSaga(explorer);
saga.put(explorerCreateNewFile('test', FileExtension.Python, Hub.Technic));
const action = await saga.take();
expect(action).toMatchInlineSnapshot(`
Object {
"fileContents": "from pybricks.hubs import TechnicHub
from pybricks.pupdevices import Motor
from pybricks.parameters import Button, Color, Direction, Port, Stop
from pybricks.robotics import DriveBase
from pybricks.tools import wait, StopWatch
hub = TechnicHub()
",
"fileName": "test.py",
"type": "fileStorage.action.writeFile",
}
`);
await saga.end();
});
});
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { put, takeEvery } from 'typed-redux-saga/macro';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import { fileStorageWriteFile } from '../fileStorage/actions';
import { explorerCreateNewFile } from './actions';
function* handleExplorerCreateNewFile(
action: ReturnType<typeof explorerCreateNewFile>,
): Generator {
const fileName = `${action.fileName}${action.fileExtension}`;
yield* put(
fileStorageWriteFile(
fileName,
getPybricksMicroPythonFileTemplate(action.hub) || '',
),
);
}
export default function* (): Generator {
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
}
+2
View File
@@ -8,6 +8,7 @@ import blePybricksService from './ble-pybricks-service/sagas';
import ble from './ble/sagas';
import editor, { EditorSagaContext } from './editor/sagas';
import errorLog from './error-log/sagas';
import explorer from './explorer/sagas';
import fileStorage from './fileStorage/sagas';
import flashFirmware, { FirmwareSagaContext } from './firmware/sagas';
import hub from './hub/sagas';
@@ -30,6 +31,7 @@ export default function* (): Generator {
lwp3BootloaderProtocol(),
editor(),
errorLog(),
explorer(),
flashFirmware(),
hub(),
licenses(),