Repository navigation
feat(documents): add a document from a file, in a code editor #2978
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
camielvs
wants to merge
5
commits into
master
Choose a base branch
from
cvs/document-file-drop
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2e4c8b5
feat(documents): add a document from a file, in a code editor
camielvs 41140d4
refactor(documents): share the text-file reader and the file-drop hook
camielvs e4b14e7
refactor(documents): name the document size limit in bytes
camielvs 6f000cf
refactor(documents): read files with the existing readTextFromFile
camielvs 27f034e
fix(documents): fill the form from the latest file read only
camielvs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
| "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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.