From 3eb9991c37b30a2ca1515943aa3319c6c77b109c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 22 Mar 2021 14:21:08 +0000 Subject: [PATCH] Better proposed UX for the Files tab. Added a "new" action but it's just a stub for now as collecting a file name needs a new dialog to be added. https://github.com/microbit-foundation/python-editor-next/issues/21 --- src/common/CollapsibleButton.tsx | 5 ++ src/common/ConfirmDialog.tsx | 1 - src/common/FileInputButton.tsx | 100 +++++++++++++------------- src/common/FilesAreaNav.tsx | 14 ++++ src/e2e/app.ts | 31 ++++---- src/e2e/multiple-files.test.ts | 2 +- src/files/FileRow.tsx | 88 +++++++++++++++-------- src/files/FilesArea.tsx | 49 ++++++++----- src/project/NewButton.tsx | 30 ++++++++ src/project/OpenButton.tsx | 13 ++-- src/project/UploadButton.tsx | 19 +++-- src/project/project-actions.tsx | 22 ++++++ src/project/project-utils.ts | 2 + src/workbench/Header.tsx | 2 +- src/workbench/LeftPanel.tsx | 25 ++++--- src/workbench/LeftPanelTabContent.tsx | 18 +++-- src/workbench/Workbench.tsx | 35 +++++---- 17 files changed, 302 insertions(+), 154 deletions(-) create mode 100644 src/common/FilesAreaNav.tsx create mode 100644 src/project/NewButton.tsx create mode 100644 src/project/project-utils.ts diff --git a/src/common/CollapsibleButton.tsx b/src/common/CollapsibleButton.tsx index 0b95bd1ed..9a2ab0317 100644 --- a/src/common/CollapsibleButton.tsx +++ b/src/common/CollapsibleButton.tsx @@ -18,6 +18,11 @@ export interface CollapsibleButtonProps buttonWidth?: number | string; } +export type CollapsableButtonComposibleProps = Omit< + CollapsibleButtonProps, + "onClick" | "text" | "icon" +>; + /** * Button that can be a regular or icon button. * diff --git a/src/common/ConfirmDialog.tsx b/src/common/ConfirmDialog.tsx index 242ee410a..6ee57177e 100644 --- a/src/common/ConfirmDialog.tsx +++ b/src/common/ConfirmDialog.tsx @@ -48,7 +48,6 @@ export const ConfirmDialog = ({ isOpen={isOpen} leastDestructiveRef={leastDestructiveRef} onClose={onCancel} - isCentered > diff --git a/src/common/FileInputButton.tsx b/src/common/FileInputButton.tsx index deba375af..216415cd2 100644 --- a/src/common/FileInputButton.tsx +++ b/src/common/FileInputButton.tsx @@ -1,8 +1,8 @@ -import { Button, ButtonProps, Input } from "@chakra-ui/react"; -import React, { useCallback, useRef } from "react"; -import { RiFolderOpenLine } from "react-icons/ri"; +import { Input } from "@chakra-ui/react"; +import React, { ForwardedRef, useCallback, useRef } from "react"; +import CollapsableButton, { CollapsibleButtonProps } from "./CollapsibleButton"; -interface OpenButtonProps extends ButtonProps { +interface FileInputButtonProps extends CollapsibleButtonProps { onOpen: (file: File) => void; /** * File input tag accept attribute. @@ -13,53 +13,57 @@ interface OpenButtonProps extends ButtonProps { /** * File open button, with an associated input field. */ -const FileInputButton = ({ - accept, - onOpen, - leftIcon = , - children, - ...props -}: OpenButtonProps) => { - const ref = useRef(null); +const FileInputButton = React.forwardRef( + ( + { accept, onOpen, icon, children, ...props }: FileInputButtonProps, + ref: ForwardedRef + ) => { + const inputRef = useRef(null); - const handleChooseFile = useCallback(() => { - ref.current && ref.current.click(); - }, []); + const handleChooseFile = useCallback(() => { + inputRef.current && inputRef.current.click(); + }, []); - const handleOpenFile = useCallback( - async (e: React.ChangeEvent) => { - const files = e.target.files; - if (files) { - const file = files.item(0); - // Clear the input so we're triggered if the user opens the same file again. - ref.current!.value = ""; - if (file) { - onOpen(file); + const handleOpenFile = useCallback( + async (e: React.ChangeEvent) => { + const files = e.target.files; + if (files) { + const file = files.item(0); + // Clear the input so we're triggered if the user opens the same file again. + inputRef.current!.value = ""; + if (file) { + onOpen(file); + } } - } - }, - [onOpen] - ); + }, + [onOpen] + ); - return ( - <> - - - - ); -}; + return ( + <> + + + {children} + + + ); + } +); export default FileInputButton; diff --git a/src/common/FilesAreaNav.tsx b/src/common/FilesAreaNav.tsx new file mode 100644 index 000000000..6f467ca97 --- /dev/null +++ b/src/common/FilesAreaNav.tsx @@ -0,0 +1,14 @@ +import { ButtonGroup } from "@chakra-ui/button"; +import NewButton from "../project/NewButton"; +import UploadButton from "../project/UploadButton"; + +const FilesAreaNav = () => { + return ( + + + + + ); +}; + +export default FilesAreaNav; diff --git a/src/e2e/app.ts b/src/e2e/app.ts index b1f6d5b24..02dbf5d86 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -121,9 +121,9 @@ export class App { * @param filename The name of the file in the file list. */ async switchToEditing(filename: string): Promise { - await this.selectSideBar("Files"); + await this.openFileActionsMenu(filename); const document = await this.document(); - const editButton = await document.findByRole("button", { + const editButton = await document.findByRole("menuitem", { name: "Edit " + filename, }); await editButton.click(); @@ -137,13 +137,12 @@ export class App { * @param filename The name of the file in the file list. */ async canSwitchToEditing(filename: string): Promise { - await this.selectSideBar("Files"); + await this.openFileActionsMenu(filename); const document = await this.document(); - await document.findByText(filename); - const editButton = await document.getByRole("button", { + const editButton = await document.findByRole("menuitem", { name: "Edit " + filename, }); - return editButton && !(await isDisabled(editButton)); + return !(await isDisabled(editButton)); } /** @@ -155,13 +154,12 @@ export class App { filename: string, dialogChoice: string = "Delete" ): Promise { - await this.selectSideBar("Files"); + await this.openFileActionsMenu(filename); const document = await this.document(); - const button = await document.findByRole("button", { + const button = await document.findByRole("menuitem", { name: "Delete " + filename, }); await button.click(); - await document.findByRole("alert"); const dialogButton = await document.findByRole("button", { name: dialogChoice, }); @@ -169,10 +167,10 @@ export class App { } async canDeleteFile(filename: string): Promise { - await this.selectSideBar("Files"); + await this.openFileActionsMenu(filename); const document = await this.document(); - const button = await document.getByRole("button", { - name: "Delete " + filename, + const button = await document.findByRole("menuitem", { + name: `Delete ${filename}`, }); return !(await isDisabled(button)); @@ -333,6 +331,15 @@ export class App { await new Promise((resolve) => setTimeout(resolve, 20)); } } + + private async openFileActionsMenu(filename: string): Promise { + await this.selectSideBar("Files"); + const document = await this.document(); + const actions = await document.findByRole("button", { + name: `${filename} file actions`, + }); + await actions.click(); + } } /** diff --git a/src/e2e/multiple-files.test.ts b/src/e2e/multiple-files.test.ts index ab9ae40c2..7767a5937 100644 --- a/src/e2e/multiple-files.test.ts +++ b/src/e2e/multiple-files.test.ts @@ -40,7 +40,7 @@ describe("Browser - multiple and missing file cases", () => { await app.findVisibleEditorContents(/Hello, World/); }); - it.only("Doesn't offer editor for non-Python file", async () => { + it("Doesn't offer editor for non-Python file", async () => { await app.uploadFile("testData/null.dat"); expect(await app.canSwitchToEditing("null.dat")).toEqual(false); diff --git a/src/files/FileRow.tsx b/src/files/FileRow.tsx index 5cbb728b4..1d6381f44 100644 --- a/src/files/FileRow.tsx +++ b/src/files/FileRow.tsx @@ -1,59 +1,91 @@ -import { Button, HStack, IconButton } from "@chakra-ui/react"; -import { RiDeleteBinLine, RiDownload2Line } from "react-icons/ri"; +import { + BoxProps, + HStack, + IconButton, + Menu, + MenuButton, + MenuItem, + MenuList, + Portal, + Text, +} from "@chakra-ui/react"; +import { MdMoreVert } from "react-icons/md"; +import { RiDeleteBin2Line, RiDownload2Line, RiEdit2Line } from "react-icons/ri"; import { MAIN_FILE } from "../fs/fs"; import { FileVersion } from "../fs/storage"; import { useProjectActions } from "../project/project-hooks"; +import { isEditableFile } from "../project/project-utils"; -interface FileRowProps { +interface FileRowProps extends BoxProps { projectName: string; value: FileVersion; - onClick: () => void; + onEdit: () => void; } /** * A row in the files area. */ -const FileRow = ({ projectName, value, onClick }: FileRowProps) => { +const FileRow = ({ projectName, value, onEdit, ...props }: FileRowProps) => { const { name } = value; const isMainFile = name === MAIN_FILE; const prettyName = isMainFile ? `${projectName} (${name})` : name; const actions = useProjectActions(); return ( - - - - } - aria-label={`Delete ${name}`} + + + actions.deleteFile(name)} + icon={} /> - } - aria-label={`Download ${name}`} - variant="ghost" - onClick={() => actions.downloadFile(name)} - /> - + + + } + isDisabled={!isEditableFile(name)} + onClick={onEdit} + aria-label={`Edit ${name}`} + > + Edit {name} + + } + onClick={() => actions.downloadFile(name)} + aria-label={`Download ${name}`} + > + Download {name} + + } + onClick={() => actions.deleteFile(name)} + isDisabled={isMainFile} + aria-label={`Delete ${name}`} + > + Delete {name} + + + + ); }; -const isEditableFile = (filename: string) => filename.match(/\.[Pp][Yy]$/); - export default FileRow; diff --git a/src/files/FilesArea.tsx b/src/files/FilesArea.tsx index dbdf2eb73..81b8aede9 100644 --- a/src/files/FilesArea.tsx +++ b/src/files/FilesArea.tsx @@ -1,33 +1,50 @@ -import { List, ListItem, VStack } from "@chakra-ui/react"; +import { Center, List, ListItem, VStack } from "@chakra-ui/react"; import OpenButton from "../project/OpenButton"; import { useProject } from "../project/project-hooks"; -import UploadButton from "../project/UploadButton"; +import { isEditableFile } from "../project/project-utils"; import FileRow from "./FileRow"; interface FilesProps { + selectedFile: string | undefined; onSelectedFileChanged: (name: string) => void; } /** * The main files area, offering access to individual files. */ -const FilesArea = ({ onSelectedFileChanged }: FilesProps) => { +const FilesArea = ({ selectedFile, onSelectedFileChanged }: FilesProps) => { const { files, name: projectName } = useProject(); return ( - - - {files.map((f) => ( - - onSelectedFileChanged(f.name)} - /> - - ))} + + + {files.map((f) => { + const select = () => { + if (isEditableFile(f.name)) { + onSelectedFileChanged(f.name); + } + }; + return ( + + + + ); + })} - Open a project - Upload a file +
+ +
); }; diff --git a/src/project/NewButton.tsx b/src/project/NewButton.tsx new file mode 100644 index 000000000..cdf66cbd1 --- /dev/null +++ b/src/project/NewButton.tsx @@ -0,0 +1,30 @@ +import { Tooltip } from "@chakra-ui/tooltip"; +import React from "react"; +import { RiFile3Line } from "react-icons/ri"; +import CollapsableButton, { + CollapsableButtonComposibleProps, +} from "../common/CollapsibleButton"; +import { useProjectActions } from "./project-hooks"; + +interface NewButtonProps extends CollapsableButtonComposibleProps {} + +/** + * Upload button, with an associated input field. + * + * This adds or updates files in the file system rather than switching project. + */ +const NewButton = (props: NewButtonProps) => { + const actions = useProjectActions(); + return ( + + } + /> + + ); +}; + +export default NewButton; diff --git a/src/project/OpenButton.tsx b/src/project/OpenButton.tsx index 5d3bc0dd2..5a12f6b34 100644 --- a/src/project/OpenButton.tsx +++ b/src/project/OpenButton.tsx @@ -1,9 +1,10 @@ -import { ButtonProps } from "@chakra-ui/react"; import React from "react"; +import { RiFolderOpenLine } from "react-icons/ri"; +import { CollapsableButtonComposibleProps } from "../common/CollapsibleButton"; import FileInputButton from "../common/FileInputButton"; import { useProjectActions } from "./project-hooks"; -interface OpenButtonProps extends ButtonProps {} +interface OpenButtonProps extends CollapsableButtonComposibleProps {} /** * Open HEX button, with an associated input field. @@ -12,14 +13,14 @@ const OpenButton = ({ children, ...props }: OpenButtonProps) => { const actions = useProjectActions(); return ( - {children} - + icon={} + /> ); }; diff --git a/src/project/UploadButton.tsx b/src/project/UploadButton.tsx index 514d9a59c..21585fc38 100644 --- a/src/project/UploadButton.tsx +++ b/src/project/UploadButton.tsx @@ -1,27 +1,26 @@ -import { ButtonProps } from "@chakra-ui/react"; -import React from "react"; -import { RiFileUploadLine } from "react-icons/ri"; +import { RiUpload2Fill } from "react-icons/ri"; +import { CollapsableButtonComposibleProps } from "../common/CollapsibleButton"; import FileInputButton from "../common/FileInputButton"; import { useProjectActions } from "./project-hooks"; -interface OpenButtonProps extends ButtonProps {} +interface UploadButtonProps extends CollapsableButtonComposibleProps {} /** * Upload button, with an associated input field. * * This adds or updates files in the file system rather than switching project. */ -const UploadButton = ({ children, ...props }: OpenButtonProps) => { +const UploadButton = (props: UploadButtonProps) => { const actions = useProjectActions(); return ( + // TODO: Tooltip breaks this, why? } - {...props} - > - {children} - + icon={} + /> ); }; diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx index 0d7262856..e13cfbac2 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -260,6 +260,28 @@ export class ProjectActions { } }; + /** + * Create a file, prompting the user for the name. + */ + createFile = async () => { + this.logging.event({ + action: "create-file", + }); + + const filename = "new file.py"; + try { + await this.fs.write( + filename, + "# Your new file!", + VersionAction.INCREMENT + ); + this.actionFeedback.success({ + title: `Created ${filename}`, + }); + } catch (e) { + this.actionFeedback.unexpectedError(e); + } + }; /** * Delete a file. * diff --git a/src/project/project-utils.ts b/src/project/project-utils.ts new file mode 100644 index 000000000..705964f38 --- /dev/null +++ b/src/project/project-utils.ts @@ -0,0 +1,2 @@ +export const isEditableFile = (filename: string) => + filename.match(/\.[Pp][Yy]$/); diff --git a/src/workbench/Header.tsx b/src/workbench/Header.tsx index 91e9bba14..61e205c4d 100644 --- a/src/workbench/Header.tsx +++ b/src/workbench/Header.tsx @@ -28,7 +28,7 @@ const Header = () => {
- Open + diff --git a/src/workbench/LeftPanel.tsx b/src/workbench/LeftPanel.tsx index 72779b11c..e646acefd 100644 --- a/src/workbench/LeftPanel.tsx +++ b/src/workbench/LeftPanel.tsx @@ -11,10 +11,11 @@ import { import React, { ReactNode, useMemo } from "react"; import { IconType } from "react-icons"; import { - RiFileListLine, + RiFolderLine, RiLayoutMasonryFill, RiSettings2Line, } from "react-icons/ri"; +import FilesAreaNav from "../common/FilesAreaNav"; import LogoBar from "../common/LogoBar"; import FilesArea from "../files/FilesArea"; import PackagesArea from "../packages/PackagesArea"; @@ -24,14 +25,15 @@ import SettingsArea from "../settings/SettingsArea"; import LeftPanelTabContent from "./LeftPanelTabContent"; interface LeftPanelProps { - onSelectedFileChanged: (file: string) => void; + selectedFile: string | undefined; + onSelectedFileChanged: (filename: string) => void; } /** * The tabbed area on the left of the UI offering access to API documentation, * files and settings. */ -const LeftPanel = ({ onSelectedFileChanged }: LeftPanelProps) => { +const LeftPanel = ({ selectedFile, onSelectedFileChanged }: LeftPanelProps) => { const panes: Pane[] = useMemo( () => [ { @@ -43,8 +45,14 @@ const LeftPanel = ({ onSelectedFileChanged }: LeftPanelProps) => { { id: "files", title: "Files", - icon: RiFileListLine, - contents: , + icon: RiFolderLine, + nav: , + contents: ( + + ), }, { id: "settings", @@ -53,7 +61,7 @@ const LeftPanel = ({ onSelectedFileChanged }: LeftPanelProps) => { contents: , }, ], - [onSelectedFileChanged] + [onSelectedFileChanged, selectedFile] ); return ; }; @@ -62,6 +70,7 @@ interface Pane { id: string; icon: IconType; title: string; + nav?: ReactNode; contents: ReactNode; } @@ -76,7 +85,7 @@ const LeftPanelContents = ({ panes }: LeftPanelContentsProps) => { {panes.map((p) => ( - + ))} @@ -88,7 +97,7 @@ const LeftPanelContents = ({ panes }: LeftPanelContentsProps) => { {panes.map((p) => ( - + {p.contents} diff --git a/src/workbench/LeftPanelTabContent.tsx b/src/workbench/LeftPanelTabContent.tsx index 8c53604bb..88fddf930 100644 --- a/src/workbench/LeftPanelTabContent.tsx +++ b/src/workbench/LeftPanelTabContent.tsx @@ -1,20 +1,28 @@ -import { Box, Flex, Text } from "@chakra-ui/react"; +import { Box, Flex, HStack, Text } from "@chakra-ui/react"; import React, { ReactNode } from "react"; interface LeftPanelTabContentProps { title: string; children: ReactNode; + nav: ReactNode; } /** * A wrapper for each area shown inside the left panel. */ -const LeftPanelTabContent = ({ title, children }: LeftPanelTabContentProps) => { +const LeftPanelTabContent = ({ + title, + children, + nav, +}: LeftPanelTabContentProps) => { return ( - - {title} - + + + {title} + + {nav} + {children} diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 1f005eb6b..b2ce4c189 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -19,19 +19,21 @@ import LeftPanel from "./LeftPanel"; * The main app layout with resizable panels. */ const Workbench = () => { - const [filename, setFilename] = useState(undefined); + const [selectedFile, setSelectedFile] = useState( + undefined + ); const { files } = useProject(); useEffect(() => { // No file yet or selected file deleted? Default it. if ( - (!filename || !files.find((x) => x.name === filename)) && + (!selectedFile || !files.find((x) => x.name === selectedFile)) && files.length > 0 ) { const defaultFile = files.find((x) => x.name === MAIN_FILE) || files[0]; - setFilename(defaultFile.name); + setSelectedFile(defaultFile.name); } - }, [filename, files]); - const fileVersion = files.find((f) => f.name === filename)?.version; + }, [selectedFile, files]); + const fileVersion = files.find((f) => f.name === selectedFile)?.version; const serialVisible = useConnectionStatus() === ConnectionStatus.CONNECTED; return ( @@ -43,16 +45,19 @@ const Workbench = () => { minimumSize={210} style={{ borderRight: "4px solid whitesmoke" }} > - + - {filename && ( + {selectedFile && ( )} @@ -65,14 +70,8 @@ const Workbench = () => { - - + +