Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/common/InputDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Button } from "@chakra-ui/button";
import {
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
} from "@chakra-ui/form-control";
import { Input } from "@chakra-ui/input";
import { VStack } from "@chakra-ui/layout";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/modal";
import { ReactNode, useRef, useState } from "react";

export interface InputDialogParameters {
header: ReactNode;
body: ReactNode;
actionLabel: string;
validate: (input: string) => string | undefined;
}

export interface InputDialogParametersWithActions
extends InputDialogParameters {
header: ReactNode;
body: ReactNode;
actionLabel: string;
onConfirm: (validValue: string) => void;
onCancel: () => void;
}

export interface InputDialogProps extends InputDialogParametersWithActions {
isOpen: boolean;
}

/**
* File name input dialog.
*
* Generally not used directly. Prefer the useDialogs hook.
*/
export const InputDialog = ({
header,
body,
actionLabel,
isOpen,
validate,
onConfirm,
onCancel,
}: InputDialogProps) => {
const [value, setValue] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const leastDestructiveRef = useRef<HTMLButtonElement>(null);
return (
<Modal isOpen={isOpen} onClose={onCancel}>
<ModalOverlay>
<ModalContent>
<ModalHeader fontSize="lg" fontWeight="bold">
{header}
</ModalHeader>
<ModalBody>
<VStack>
{body}
<FormControl id="fileName" isRequired isInvalid={Boolean(error)}>
<FormLabel>Name</FormLabel>
<Input
type="text"
value={value}
onChange={(e) => {
const value = e.target.value;
setValue(value);
setError(validate(value));
}}
></Input>
<FormHelperText>
We'll add the ".py" extension for you.
</FormHelperText>
<FormErrorMessage>{error}</FormErrorMessage>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button ref={leastDestructiveRef} onClick={onCancel}>
Cancel
</Button>
<Button
colorScheme="blue"
onClick={() => onConfirm(value)}
ml={3}
isDisabled={Boolean(error)}
>
{actionLabel}
</Button>
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
);
};
36 changes: 33 additions & 3 deletions src/common/use-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import {
ConfirmDialogParametersWithActions,
ConfirmDialog,
} from "./ConfirmDialog";
import {
InputDialog,
InputDialogParameters,
InputDialogParametersWithActions,
} from "./InputDialog";

const DialogContext = React.createContext<Dialogs | undefined>(undefined);

Expand All @@ -12,14 +17,22 @@ interface DialogProviderProps {
}

export const DialogProvider = ({ children }: DialogProviderProps) => {
const [state, setState] = useState<
const [confirmDialogState, setConfirmDialogState] = useState<
ConfirmDialogParametersWithActions | undefined
>(undefined);
const dialogs = useMemo(() => new Dialogs(setState), [setState]);
const [inputDialogState, setInputDialogState] = useState<
InputDialogParametersWithActions | undefined
>(undefined);

const dialogs = useMemo(
() => new Dialogs(setConfirmDialogState, setInputDialogState),
[setConfirmDialogState]
);
return (
<DialogContext.Provider value={dialogs}>
<>
{state && <ConfirmDialog isOpen {...state} />}
{confirmDialogState && <ConfirmDialog isOpen {...confirmDialogState} />}
{inputDialogState && <InputDialog isOpen {...inputDialogState} />}
{children}
</>
</DialogContext.Provider>
Expand All @@ -30,6 +43,9 @@ export class Dialogs {
constructor(
private confirmDialogSetState: (
options: ConfirmDialogParametersWithActions | undefined
) => void,
private inputDialogSetState: (
options: InputDialogParametersWithActions | undefined
) => void
) {}

Expand All @@ -46,6 +62,20 @@ export class Dialogs {
});
});
}

async input(options: InputDialogParameters): Promise<string | undefined> {
return new Promise((_resolve) => {
const resolve = (result: string | undefined) => {
this.inputDialogSetState(undefined);
_resolve(result);
};
this.inputDialogSetState({
...options,
onCancel: () => resolve(undefined),
onConfirm: (validValue: string) => resolve(validValue),
});
});
}
}

export const useDialogs = () => {
Expand Down
23 changes: 23 additions & 0 deletions src/e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as os from "os";
import * as path from "path";
import "pptr-testing-library/extend";
import puppeteer, { ElementHandle, Page } from "puppeteer";
import { createBinary } from "typescript";

export interface BrowserDownload {
filename: string;
Expand Down Expand Up @@ -52,6 +53,28 @@ export class App {
await openInput.uploadFile(filePath);
}

/**
* Create a new file using the files tab.
*
* @param name The name to enter in the dialog.
*/
async createNewFile(name: string): Promise<void> {
await this.selectSideBar("Files");
const document = await this.document();
const newButton = await document.findByRole("button", {
name: "Create new file",
});
await newButton.click();
const nameField = await document.findByRole("textbox", {
name: "Name",
});
await nameField.type(name);
const createButton = await document.findByRole("button", {
name: "Create",
});
await createButton.click();
}

/**
* Upload a file to the file system using the file chooser.
*
Expand Down
9 changes: 9 additions & 0 deletions src/e2e/multiple-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ describe("Browser - multiple and missing file cases", () => {
);
});

it("Create a new file", async () => {
await app.createNewFile("test");

// This should happen automatically but is not yet implemented.
await app.switchToEditing("test.py");

await app.findVisibleEditorContents(/Your new file/);
});

it("Prevents deleting main.py", async () => {
expect(await app.canDeleteFile("main.py")).toEqual(false);
});
Expand Down
39 changes: 26 additions & 13 deletions src/project/project-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { saveAs } from "file-saver";
import Separate, { br } from "../common/Separate";
import { ActionFeedback } from "../common/use-action-feedback";
import { Dialogs } from "../common/use-dialogs";
import { BoardId } from "../device/board-id";
import {
ConnectionStatus,
Expand All @@ -17,7 +18,7 @@ import {
import { VersionAction } from "../fs/storage";
import { Logging } from "../logging/logging";
import translation from "../translation";
import { Dialogs } from "../common/use-dialogs";
import { ensurePythonExtension, validateNewFilename } from "./project-utils";

class HexGenerationError extends Error {}

Expand Down Expand Up @@ -268,18 +269,30 @@ export class ProjectActions {
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);
const preexistingFiles = new Set(this.fs.project.files.map((f) => f.name));
const validate = (filename: string) =>
validateNewFilename(filename, (f) => preexistingFiles.has(f));
const filenameWithoutExtension = await this.dialogs.input({
header: "Create a new Python file",
body: null,
actionLabel: "Create",
validate,
});

if (filenameWithoutExtension) {
try {
const filename = ensurePythonExtension(filenameWithoutExtension);
await this.fs.write(
filename,
"# Your new file!",
VersionAction.INCREMENT
);
this.actionFeedback.success({
title: `Created ${filename}`,
});
} catch (e) {
this.actionFeedback.unexpectedError(e);
}
}
};
/**
Expand Down
31 changes: 31 additions & 0 deletions src/project/project-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { validateNewFilename } from "./project-utils";

describe("validateNewFilename", () => {
const exists = (filename: string) => filename === "main.py";

it("errors for Python extensions", () => {
expect(validateNewFilename("foo.py", exists)).toEqual(
"Python files should have lowercase names with no spaces"
);
});
it("errors for spaces", () => {
expect(validateNewFilename("spaces are not allowed", exists)).toEqual(
"Python files should have lowercase names with no spaces"
);
});
it("errors for uppercase", () => {
expect(validateNewFilename("OHNO", exists)).toEqual(
"Python files should have lowercase names with no spaces"
);
});
it("errors for file clashes", () => {
expect(validateNewFilename("main", exists)).toEqual(
"This file already exists"
);
});
it("accepts valid names", () => {
expect(
validateNewFilename("underscores_are_allowed", exists)
).toBeUndefined();
});
});
33 changes: 31 additions & 2 deletions src/project/project-utils.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,31 @@
export const isEditableFile = (filename: string) =>
filename.match(/\.[Pp][Yy]$/);
export const isPythonFile = (filename: string) => filename.match(/\.[Pp][Yy]$/);

export const ensurePythonExtension = (filename: string) =>
isPythonFile(filename) ? filename : `${filename}.py`;

export const isEditableFile = isPythonFile;

/**
* From https://www.python.org/dev/peps/pep-0008/#package-and-module-names
*
* "Modules should have short, all-lowercase names.
* Underscores can be used in the module name if it improves readability."
*
* @param filename The name to check. May be user input without a file extension.
* @param exists A function to check whether a file exists.
*/
export const validateNewFilename = (
filename: string,
exists: (f: string) => boolean
): string | undefined => {
if (filename.length === 0) {
return "The name cannot be empty";
}
if (!filename.match(/^[\p{Ll}_]+$/u)) {
return "Python files should have lowercase names with no spaces";
}
if (exists(ensurePythonExtension(filename))) {
return "This file already exists";
}
return undefined;
};