diff --git a/src/editor/pybricksMicroPython.ts b/src/editor/pybricksMicroPython.ts index 37b18e05..c47e938c 100644 --- a/src/editor/pybricksMicroPython.ts +++ b/src/editor/pybricksMicroPython.ts @@ -253,42 +253,57 @@ export const language = { }, }; -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 - > & { 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 = { provideCompletionItems: (model, position, _context, _token) => { // templates snippets are only available on the first line diff --git a/src/explorer/Explorer.test.tsx b/src/explorer/Explorer.test.tsx index b75155ea..0805a31d 100644 --- a/src/explorer/Explorer.test.tsx +++ b/src/explorer/Explorer.test.tsx @@ -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(); + + 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(, { diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 7fbe87c1..2343e6ad 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -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 = () => { alert('not implemented')} + onClick={() => setIsNewFileWizardOpen(true)} + /> + setIsNewFileWizardOpen(false)} /> diff --git a/src/explorer/NewFileWizard.tsx b/src/explorer/NewFileWizard.tsx new file mode 100644 index 00000000..729c0ea8 --- /dev/null +++ b/src/explorer/NewFileWizard.tsx @@ -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, +): 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 = ( + 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: a…z, + underscore: _, + }, + )} + + ); + case FileNameValidationResult.HasInvalidCharacters: + return ( + <> + {i18n.translate( + NewFileWizardStringId.FileNameHelpTextHasInvalidCharacters, + { + letters: a…z, + numbers: 0…9, + dash: -, + underscore: _, + }, + )} + + ); + case FileNameValidationResult.AlreadyExists: + return ( + <> + {i18n.translate( + NewFileWizardStringId.FileNameHelpTextAlreadyExists, + )} + + ); + } +}; + +type NewFileWizardProps = { + readonly isOpen: boolean; + readonly onClose: () => void; +}; + +const NewFileWizard: React.VoidFunctionComponent = (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(null); + + const fileNameIntent = + fileNameValidation === FileNameValidationResult.IsOk ? 'none' : 'danger'; + + const handleFileNameChanged = (fileName: string) => { + setFileNameValidation( + validateFileName(fileName, FileExtension.Python, fileNames), + ); + setFileName(fileName); + }; + + return ( + handleFileNameChanged('')} + onOpened={() => fileNameInputRef.current?.focus()} + onClose={() => props.onClose()} + > +
+ } + > + {FileExtension.Python}} + onChange={(e) => handleFileNameChanged(e.target.value)} + /> + + + setHubType(e.currentTarget.value as Hub)} + > + BOOST Move Hub + City Hub + Technic Hub + SPIKE Prime + SPIKE Essential + MINDSTORMS Robot Inventor + + +
+
+
+ +
+
+
+ ); +}; + +export default NewFileWizard; diff --git a/src/explorer/NewFileWizart.test.tsx b/src/explorer/NewFileWizart.test.tsx new file mode 100644 index 00000000..5ad3b1a6 --- /dev/null +++ b/src/explorer/NewFileWizart.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts new file mode 100644 index 00000000..4f79e190 --- /dev/null +++ b/src/explorer/actions.ts @@ -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, + }), +); diff --git a/src/explorer/i18n.en.json b/src/explorer/i18n.en.json index ccd706f9..0dfcd595 100644 --- a/src/explorer/i18n.en.json +++ b/src/explorer/i18n.en.json @@ -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" + } } } diff --git a/src/explorer/i18n.test.ts b/src/explorer/i18n.test.ts index ad68f26f..6c478b14 100644 --- a/src/explorer/i18n.test.ts +++ b/src/explorer/i18n.test.ts @@ -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(); + }); +}); diff --git a/src/explorer/i18n.ts b/src/explorer/i18n.ts index 8a3502db..582bbc3f 100644 --- a/src/explorer/i18n.ts +++ b/src/explorer/i18n.ts @@ -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', +} diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts new file mode 100644 index 00000000..c5a0d101 --- /dev/null +++ b/src/explorer/sagas.test.ts @@ -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(); + }); +}); diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts new file mode 100644 index 00000000..919a3299 --- /dev/null +++ b/src/explorer/sagas.ts @@ -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, +): Generator { + const fileName = `${action.fileName}${action.fileExtension}`; + + yield* put( + fileStorageWriteFile( + fileName, + getPybricksMicroPythonFileTemplate(action.hub) || '', + ), + ); +} + +export default function* (): Generator { + yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile); +} diff --git a/src/sagas.ts b/src/sagas.ts index 254f98c0..a9fe4e96 100644 --- a/src/sagas.ts +++ b/src/sagas.ts @@ -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(),