Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/components/Project/AddDocumentDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ vi.mock("@/providers/AnalyticsProvider", () => ({
useAnalytics: () => ({ track }),
}));

vi.mock("@/components/shared/CodeViewer/CodeEditor", () => ({
default: ({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: { ariaLabel: string };
}) => (
<textarea
aria-label={options.ariaLabel}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));

function mockCreate({ isPending = false } = {}) {
vi.mocked(useCreateProjectResource).mockReturnValue({
mutate,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Project/AddDocumentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function AddDocumentDialog({

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Add Document</DialogTitle>
</DialogHeader>
Expand Down
77 changes: 77 additions & 0 deletions src/components/Project/DocumentFileDropZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { type ChangeEvent, useRef } from "react";

import { Button } from "@/components/ui/button";
import { FieldMessage } from "@/components/ui/field-message";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import { cn } from "@/lib/utils";
import { tracking } from "@/utils/tracking";

interface DocumentFileDropZoneProps {
isDraggingFile: boolean;
error: string | null;
disabled: boolean;
onFileChosen: (file: File) => void;
}

export function DocumentFileDropZone({
isDraggingFile,
error,
disabled,
onFileChosen,
}: DocumentFileDropZoneProps) {
const fileInputRef = useRef<HTMLInputElement>(null);

const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) onFileChosen(file);
};

return (
<BlockStack gap="1" aria-live="polite">
<InlineStack
gap="2"
align="center"
className={cn(
Comment thread
camielvs marked this conversation as resolved.
Resolved
"w-full rounded-md border border-dashed px-3 py-3 transition-colors",
isDraggingFile ? "border-primary bg-primary/5" : "border-input",
)}
>
<Icon name="FileUp" size="sm" className="text-muted-foreground" />
{isDraggingFile ? (
<Text size="xs" tone="subdued">
Drop to fill in the title and content
</Text>
) : (
<Text size="xs" tone="subdued">
Drop a text file here, or{" "}
<Button
type="button"
variant="link"
size="inline-xs"
disabled={disabled}
onClick={() => fileInputRef.current?.click()}
{...tracking("projects.add_document_browse_file")}
>
browse
</Button>
</Text>
)}
<input
ref={fileInputRef}
type="file"
hidden
onChange={handleFileChange}
data-testid="document-file-input"
/>
</InlineStack>
{error && (
<FieldMessage tone="critical" icon="CircleAlert">
{error}
</FieldMessage>
)}
</BlockStack>
);
}
242 changes: 242 additions & 0 deletions src/components/Project/DocumentForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";

import { DocumentForm } from "./DocumentForm";

const track = vi.fn();

vi.mock("@/providers/AnalyticsProvider", () => ({
useAnalytics: () => ({ track }),
}));

vi.mock("@/components/shared/CodeViewer/CodeEditor", () => ({
default: ({
value,
language,
onChange,
options,
}: {
value: string;
language: string;
onChange: (value: string) => void;
options: { ariaLabel: string };
}) => (
<textarea
aria-label={options.ariaLabel}
data-language={language}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));

const titleField = () => screen.getByLabelText("Title");
const contentField = () => screen.getByLabelText("Content");
const form = () => titleField().closest("form") as HTMLFormElement;
const draggingHint = () =>
screen.queryByText("Drop to fill in the title and content");

function renderForm({ isSubmitting = false } = {}) {
render(
<DocumentForm
onSubmit={vi.fn()}
onCancel={vi.fn()}
isSubmitting={isSubmitting}
/>,
);
}

function fileDrag(...files: File[]) {
return { dataTransfer: { files, types: ["Files"] } };
}

const modelCard = () => new File(["Trained on Q3 data"], "model-card.md");

function fileReadOnCue(text: string, name: string) {
const file = new File([text], name);
let finishReading = () => {};
const bytes = new Promise<ArrayBuffer>((resolve) => {
finishReading = () => resolve(new TextEncoder().encode(text).buffer);
});
vi.spyOn(file, "arrayBuffer").mockReturnValue(bytes);
return { file, finishReading };
}

const addButton = () => screen.getByRole("button", { name: "Add" });
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));

describe("DocumentForm file drop", () => {
afterEach(() => {
vi.resetAllMocks();
});

it("fills the title and content from a dropped file", async () => {
renderForm();

fireEvent.drop(contentField(), fileDrag(modelCard()));

await waitFor(() => {
expect(contentField()).toHaveValue("Trained on Q3 data");
});
expect(titleField()).toHaveValue("model-card.md");
expect(track).toHaveBeenCalledWith(
"projects.add_document_from_file_completed",
{ method: "drop" },
);
});

it("highlights the content as the title's extension says, as the saved document will be", async () => {
const user = userEvent.setup();
renderForm();

expect(contentField()).toHaveAttribute("data-language", "plaintext");

await user.type(titleField(), "train.py");
expect(contentField()).toHaveAttribute("data-language", "python");
});

it("fills the title and content from a browsed file", async () => {
const user = userEvent.setup();
renderForm();

await user.upload(screen.getByTestId("document-file-input"), modelCard());

await waitFor(() => {
expect(contentField()).toHaveValue("Trained on Q3 data");
});
expect(titleField()).toHaveValue("model-card.md");
expect(track).toHaveBeenCalledWith(
"projects.add_document_from_file_completed",
{ method: "browse" },
);
});

it("keeps a title the user typed", async () => {
const user = userEvent.setup();
renderForm();

await user.type(titleField(), "Q3 model card");
fireEvent.drop(form(), fileDrag(modelCard()));

await waitFor(() => {
expect(contentField()).toHaveValue("Trained on Q3 data");
});
expect(titleField()).toHaveValue("Q3 model card");
});

it("replaces a title it took from an earlier file", async () => {
renderForm();

fireEvent.drop(form(), fileDrag(new File(["wrong"], "wrong-file.txt")));
await waitFor(() => {
expect(titleField()).toHaveValue("wrong-file.txt");
});

fireEvent.drop(form(), fileDrag(modelCard()));
await waitFor(() => {
expect(titleField()).toHaveValue("model-card.md");
});
expect(contentField()).toHaveValue("Trained on Q3 data");
});

it("keeps the later file when an earlier one finishes reading last", async () => {
renderForm();
const first = fileReadOnCue("first draft", "first.md");
const second = fileReadOnCue("second draft", "second.md");

fireEvent.drop(form(), fileDrag(first.file));
fireEvent.drop(form(), fileDrag(second.file));

second.finishReading();
await waitFor(() => {
expect(contentField()).toHaveValue("second draft");
});

await act(async () => {
first.finishReading();
await settle();
});
expect(contentField()).toHaveValue("second draft");
expect(titleField()).toHaveValue("second.md");
});

it("holds off adding until a dropped file has been read", async () => {
const user = userEvent.setup();
renderForm();
await user.type(titleField(), "notes.md");
await user.type(contentField(), "Draft");
expect(addButton()).toBeEnabled();

const slow = fileReadOnCue("Trained on Q3 data", "model-card.md");
fireEvent.drop(form(), fileDrag(slow.file));
expect(addButton()).toBeDisabled();

slow.finishReading();
await waitFor(() => {
expect(addButton()).toBeEnabled();
});
expect(contentField()).toHaveValue("Trained on Q3 data");
});

it("says why a file that isn't text can't be added, and leaves the fields alone", async () => {
const user = userEvent.setup();
renderForm();

await user.type(contentField(), "Draft");
fireEvent.drop(
form(),
fileDrag(new File([new Uint8Array([0xff, 0xfe, 0x00])], "chart.png")),
);

expect(
await screen.findByText(
"chart.png isn't a text file. Only text can be added as a document.",
),
).toBeInTheDocument();
expect(contentField()).toHaveValue("Draft");
expect(titleField()).toHaveValue("");
});

it("clears the error once a readable file arrives", async () => {
renderForm();

fireEvent.drop(
form(),
fileDrag(new File([new Uint8Array([0xff])], "chart.png")),
);
await screen.findByText(/chart\.png isn't a text file/);

fireEvent.drop(form(), fileDrag(modelCard()));
await waitFor(() => {
expect(screen.queryByText(/chart\.png isn't a text file/)).toBeNull();
});
});

it("ignores a file dropped while the document is saving", async () => {
renderForm({ isSubmitting: true });

fireEvent.drop(form(), fileDrag(modelCard()));

await waitFor(() => {
expect(track).not.toHaveBeenCalled();
});
expect(contentField()).toHaveValue("");
});

it("highlights while a file is dragged over the form", () => {
renderForm();

fireEvent.dragEnter(form(), fileDrag());
expect(draggingHint()).toBeInTheDocument();

fireEvent.dragLeave(form(), fileDrag());
expect(draggingHint()).toBeNull();
});
});
Loading
Loading