From e878741ae748222b34bb724cf71bc5e2881a7172 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Thu, 24 Oct 2024 18:19:41 +0400 Subject: [PATCH] test(frontend): Test, refactor, and improve the chat input (#4535) --- .../components/chat/ChatInput.test.tsx | 119 ----------- .../components/chat/chat-input.test.tsx | 161 +++++++++++++++ .../components/image-preview.test.tsx | 32 +++ .../components/interactive-chat-box.test.tsx | 119 +++++++++++ .../components/upload-image-input.test.tsx | 71 +++++++ frontend/src/assets/chevron-left.tsx | 28 +++ frontend/src/assets/chevron-right.tsx | 28 +++ frontend/src/assets/close.svg | 5 + .../src/components/attach-image-label.tsx | 10 + frontend/src/components/chat-input.tsx | 108 ++++++++++ frontend/src/components/chat/ChatInput.tsx | 162 --------------- .../src/components/chat/ChatInterface.tsx | 23 ++- frontend/src/components/image-carousel.tsx | 74 +++++++ frontend/src/components/image-preview.tsx | 39 ++++ .../src/components/interactive-chat-box.tsx | 69 +++++++ .../src/components/upload-image-input.tsx | 26 +++ frontend/src/routes/_oh._index/route.tsx | 42 +--- frontend/src/routes/_oh._index/task-form.tsx | 194 ++++++------------ .../_oh._index/uploaded-file-preview.tsx | 23 --- frontend/src/state/initial-query-slice.ts | 4 +- frontend/src/utils/convert-zip-to-base64.ts | 10 + frontend/src/utils/get-random-key.ts | 6 + package-lock.json | 111 ++++++++++ package.json | 5 + 24 files changed, 983 insertions(+), 486 deletions(-) delete mode 100644 frontend/__tests__/components/chat/ChatInput.test.tsx create mode 100644 frontend/__tests__/components/chat/chat-input.test.tsx create mode 100644 frontend/__tests__/components/image-preview.test.tsx create mode 100644 frontend/__tests__/components/interactive-chat-box.test.tsx create mode 100644 frontend/__tests__/components/upload-image-input.test.tsx create mode 100644 frontend/src/assets/chevron-left.tsx create mode 100644 frontend/src/assets/chevron-right.tsx create mode 100644 frontend/src/assets/close.svg create mode 100644 frontend/src/components/attach-image-label.tsx create mode 100644 frontend/src/components/chat-input.tsx delete mode 100644 frontend/src/components/chat/ChatInput.tsx create mode 100644 frontend/src/components/image-carousel.tsx create mode 100644 frontend/src/components/image-preview.tsx create mode 100644 frontend/src/components/interactive-chat-box.tsx create mode 100644 frontend/src/components/upload-image-input.tsx delete mode 100644 frontend/src/routes/_oh._index/uploaded-file-preview.tsx create mode 100644 frontend/src/utils/convert-zip-to-base64.ts create mode 100644 frontend/src/utils/get-random-key.ts create mode 100644 package-lock.json create mode 100644 package.json diff --git a/frontend/__tests__/components/chat/ChatInput.test.tsx b/frontend/__tests__/components/chat/ChatInput.test.tsx deleted file mode 100644 index 75b565f848..0000000000 --- a/frontend/__tests__/components/chat/ChatInput.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import userEvent from "@testing-library/user-event"; -import { render, screen } from "@testing-library/react"; -import { describe, afterEach, vi, it, expect } from "vitest"; -import ChatInput from "#/components/chat/ChatInput"; - -describe.skip("ChatInput", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - const onSendMessage = vi.fn(); - - it("should render a textarea", () => { - render(); - expect(screen.getByRole("textbox")).toBeInTheDocument(); - }); - - it("should be able to be set as disabled", async () => { - const user = userEvent.setup(); - render(); - - const textarea = screen.getByRole("textbox"); - const button = screen.getByRole("button"); - - expect(textarea).not.toBeDisabled(); // user can still type - expect(button).toBeDisabled(); // user cannot submit - - await user.type(textarea, "Hello, world!"); - await user.keyboard("{Enter}"); - - expect(onSendMessage).not.toHaveBeenCalled(); - }); - - it("should render with a placeholder", () => { - render(); - - const textarea = screen.getByPlaceholderText( - /CHAT_INTERFACE\$INPUT_PLACEHOLDER/i, - ); - expect(textarea).toBeInTheDocument(); - }); - - it("should render a send button", () => { - render(); - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - - it("should call sendChatMessage with the input when the send button is clicked", async () => { - const user = userEvent.setup(); - render(); - - const textarea = screen.getByRole("textbox"); - const button = screen.getByRole("button"); - - await user.type(textarea, "Hello, world!"); - await user.click(button); - - expect(onSendMessage).toHaveBeenCalledWith("Hello, world!", []); - // Additionally, check if it was called exactly once - expect(onSendMessage).toHaveBeenCalledTimes(1); - }); - - it("should be able to send a message when the enter key is pressed", async () => { - const user = userEvent.setup(); - render(); - const textarea = screen.getByRole("textbox"); - - await user.type(textarea, "Hello, world!"); - await user.keyboard("{Enter}"); - - expect(onSendMessage).toHaveBeenCalledWith("Hello, world!", []); - }); - - it("should NOT send a message when shift + enter is pressed", async () => { - const user = userEvent.setup(); - render(); - const textarea = screen.getByRole("textbox"); - - await user.type(textarea, "Hello, world!"); - await user.keyboard("{Shift>} {Enter}"); // Shift + Enter - - expect(onSendMessage).not.toHaveBeenCalled(); - }); - - it("should NOT send an empty message", async () => { - const user = userEvent.setup(); - render(); - const textarea = screen.getByRole("textbox"); - const button = screen.getByRole("button"); - - await user.type(textarea, " "); - - // with enter key - await user.keyboard("{Enter}"); - expect(onSendMessage).not.toHaveBeenCalled(); - - // with button click - await user.click(button); - expect(onSendMessage).not.toHaveBeenCalled(); - }); - - it("should clear the input message after sending a message", async () => { - const user = userEvent.setup(); - render(); - const textarea = screen.getByRole("textbox"); - const button = screen.getByRole("button"); - - await user.type(textarea, "Hello, world!"); - expect(textarea).toHaveValue("Hello, world!"); - - await user.click(button); - expect(textarea).toHaveValue(""); - }); - - // this is already implemented but need to figure out how to test it - it.todo( - "should NOT send a message when the enter key is pressed while composing", - ); -}); diff --git a/frontend/__tests__/components/chat/chat-input.test.tsx b/frontend/__tests__/components/chat/chat-input.test.tsx new file mode 100644 index 0000000000..e10e3d26f3 --- /dev/null +++ b/frontend/__tests__/components/chat/chat-input.test.tsx @@ -0,0 +1,161 @@ +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { describe, afterEach, vi, it, expect } from "vitest"; +import { ChatInput } from "#/components/chat-input"; + +describe("ChatInput", () => { + const onSubmitMock = vi.fn(); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render a textarea", () => { + render(); + expect(screen.getByTestId("chat-input")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("should call onSubmit when the user types and presses enter", async () => { + const user = userEvent.setup(); + render(); + const textarea = screen.getByRole("textbox"); + + await user.type(textarea, "Hello, world!"); + await user.keyboard("{Enter}"); + + expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!"); + }); + + it("should call onSubmit when pressing the submit button", async () => { + const user = userEvent.setup(); + render(); + const textarea = screen.getByRole("textbox"); + const button = screen.getByRole("button"); + + await user.type(textarea, "Hello, world!"); + await user.click(button); + + expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!"); + }); + + it("should not call onSubmit when the message is empty", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button"); + + await user.click(button); + expect(onSubmitMock).not.toHaveBeenCalled(); + + await user.keyboard("{Enter}"); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + + it("should disable submit", async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole("button"); + const textarea = screen.getByRole("textbox"); + + await user.type(textarea, "Hello, world!"); + + expect(button).toBeDisabled(); + await user.click(button); + expect(onSubmitMock).not.toHaveBeenCalled(); + + await user.keyboard("{Enter}"); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + + it("should render a placeholder", () => { + render( + , + ); + + const textarea = screen.getByPlaceholderText("Enter your message"); + expect(textarea).toBeInTheDocument(); + }); + + it("should create a newline instead of submitting when shift + enter is pressed", async () => { + const user = userEvent.setup(); + render(); + const textarea = screen.getByRole("textbox"); + + await user.type(textarea, "Hello, world!"); + await user.keyboard("{Shift>} {Enter}"); // Shift + Enter + + expect(onSubmitMock).not.toHaveBeenCalled(); + // expect(textarea).toHaveValue("Hello, world!\n"); + }); + + it("should clear the input message after sending a message", async () => { + const user = userEvent.setup(); + render(); + const textarea = screen.getByRole("textbox"); + const button = screen.getByRole("button"); + + await user.type(textarea, "Hello, world!"); + await user.keyboard("{Enter}"); + expect(textarea).toHaveValue(""); + + await user.type(textarea, "Hello, world!"); + await user.click(button); + expect(textarea).toHaveValue(""); + }); + + it("should hide the submit button", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("should call onChange when the user types", async () => { + const user = userEvent.setup(); + const onChangeMock = vi.fn(); + render(); + const textarea = screen.getByRole("textbox"); + + await user.type(textarea, "Hello, world!"); + + expect(onChangeMock).toHaveBeenCalledTimes("Hello, world!".length); + }); + + it("should have set the passed value", () => { + render(); + const textarea = screen.getByRole("textbox"); + + expect(textarea).toHaveValue("Hello, world!"); + }); + + it("should display the stop button and trigger the callback", async () => { + const user = userEvent.setup(); + const onStopMock = vi.fn(); + render( + , + ); + const stopButton = screen.getByTestId("stop-button"); + + await user.click(stopButton); + expect(onStopMock).toHaveBeenCalledOnce(); + }); + + it("should call onFocus and onBlur when the textarea is focused and blurred", async () => { + const user = userEvent.setup(); + const onFocusMock = vi.fn(); + const onBlurMock = vi.fn(); + render( + , + ); + const textarea = screen.getByRole("textbox"); + + await user.click(textarea); + expect(onFocusMock).toHaveBeenCalledOnce(); + + await user.tab(); + expect(onBlurMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/__tests__/components/image-preview.test.tsx b/frontend/__tests__/components/image-preview.test.tsx new file mode 100644 index 0000000000..ef39fb0f47 --- /dev/null +++ b/frontend/__tests__/components/image-preview.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ImagePreview } from "#/components/image-preview"; + +describe("ImagePreview", () => { + it("should render an image", () => { + render( + , + ); + const img = screen.getByRole("img"); + + expect(screen.getByTestId("image-preview")).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "https://example.com/image.jpg"); + }); + + it("should call onRemove when the close button is clicked", async () => { + const user = userEvent.setup(); + const onRemoveMock = vi.fn(); + render( + , + ); + + const closeButton = screen.getByRole("button"); + await user.click(closeButton); + + expect(onRemoveMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx new file mode 100644 index 0000000000..5d775682cb --- /dev/null +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { InteractiveChatBox } from "#/components/interactive-chat-box"; + +describe("InteractiveChatBox", () => { + const onSubmitMock = vi.fn(); + const onStopMock = vi.fn(); + + beforeAll(() => { + global.URL.createObjectURL = vi + .fn() + .mockReturnValue("blob:http://example.com"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render", () => { + render(); + + const chatBox = screen.getByTestId("interactive-chat-box"); + within(chatBox).getByTestId("chat-input"); + within(chatBox).getByTestId("upload-image-input"); + }); + + it("should display the image previews when images are uploaded", async () => { + const user = userEvent.setup(); + render(); + + const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }); + const input = screen.getByTestId("upload-image-input"); + + expect(screen.queryAllByTestId("image-preview")).toHaveLength(0); + + await user.upload(input, file); + expect(screen.queryAllByTestId("image-preview")).toHaveLength(1); + + const files = [ + new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }), + new File(["(⌐□_□)"], "chucknorris3.png", { type: "image/png" }), + ]; + + await user.upload(input, files); + expect(screen.queryAllByTestId("image-preview")).toHaveLength(3); + }); + + it("should remove the image preview when the close button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }); + const input = screen.getByTestId("upload-image-input"); + + await user.upload(input, file); + expect(screen.queryAllByTestId("image-preview")).toHaveLength(1); + + const imagePreview = screen.getByTestId("image-preview"); + const closeButton = within(imagePreview).getByRole("button"); + await user.click(closeButton); + + expect(screen.queryAllByTestId("image-preview")).toHaveLength(0); + }); + + it("should call onSubmit with the message and images", async () => { + const user = userEvent.setup(); + render(); + + const textarea = within(screen.getByTestId("chat-input")).getByRole( + "textbox", + ); + const input = screen.getByTestId("upload-image-input"); + const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }); + + await user.upload(input, file); + await user.type(textarea, "Hello, world!"); + await user.keyboard("{Enter}"); + + expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file]); + + // clear images after submission + expect(screen.queryAllByTestId("image-preview")).toHaveLength(0); + }); + + it("should disable the submit button", async () => { + const user = userEvent.setup(); + render( + , + ); + + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + + await user.click(button); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + + it("should display the stop button if set and call onStop when clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const stopButton = screen.getByTestId("stop-button"); + expect(stopButton).toBeInTheDocument(); + + await user.click(stopButton); + expect(onStopMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/__tests__/components/upload-image-input.test.tsx b/frontend/__tests__/components/upload-image-input.test.tsx new file mode 100644 index 0000000000..77f89ee885 --- /dev/null +++ b/frontend/__tests__/components/upload-image-input.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { UploadImageInput } from "#/components/upload-image-input"; + +describe("UploadImageInput", () => { + const user = userEvent.setup(); + const onUploadMock = vi.fn(); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render an input", () => { + render(); + expect(screen.getByTestId("upload-image-input")).toBeInTheDocument(); + }); + + it("should call onUpload when a file is selected", async () => { + render(); + + const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }); + const input = screen.getByTestId("upload-image-input"); + + await user.upload(input, file); + + expect(onUploadMock).toHaveBeenNthCalledWith(1, [file]); + }); + + it("should call onUpload when multiple files are selected", async () => { + render(); + + const files = [ + new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }), + new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }), + ]; + const input = screen.getByTestId("upload-image-input"); + + await user.upload(input, files); + + expect(onUploadMock).toHaveBeenNthCalledWith(1, files); + }); + + it("should not upload any file that is not an image", async () => { + render(); + + const file = new File(["(⌐□_□)"], "chucknorris.txt", { + type: "text/plain", + }); + const input = screen.getByTestId("upload-image-input"); + + await user.upload(input, file); + + expect(onUploadMock).not.toHaveBeenCalled(); + }); + + it("should render custom labels", () => { + const { rerender } = render(); + expect(screen.getByTestId("default-label")).toBeInTheDocument(); + + function CustomLabel() { + return Custom label; + } + rerender( + } />, + ); + + expect(screen.getByText("Custom label")).toBeInTheDocument(); + expect(screen.queryByTestId("default-label")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/assets/chevron-left.tsx b/frontend/src/assets/chevron-left.tsx new file mode 100644 index 0000000000..b2ae9abdde --- /dev/null +++ b/frontend/src/assets/chevron-left.tsx @@ -0,0 +1,28 @@ +interface ChevronLeftProps { + width?: number; + height?: number; + active?: boolean; +} + +export function ChevronLeft({ + width = 20, + height = 20, + active, +}: ChevronLeftProps) { + return ( + + + + ); +} diff --git a/frontend/src/assets/chevron-right.tsx b/frontend/src/assets/chevron-right.tsx new file mode 100644 index 0000000000..4d7119d4a9 --- /dev/null +++ b/frontend/src/assets/chevron-right.tsx @@ -0,0 +1,28 @@ +interface ChevronRightProps { + width?: number; + height?: number; + active?: boolean; +} + +export function ChevronRight({ + width = 20, + height = 20, + active, +}: ChevronRightProps) { + return ( + + + + ); +} diff --git a/frontend/src/assets/close.svg b/frontend/src/assets/close.svg new file mode 100644 index 0000000000..d43761a6f1 --- /dev/null +++ b/frontend/src/assets/close.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/components/attach-image-label.tsx b/frontend/src/components/attach-image-label.tsx new file mode 100644 index 0000000000..f3b9c7ebc1 --- /dev/null +++ b/frontend/src/components/attach-image-label.tsx @@ -0,0 +1,10 @@ +import Clip from "#/assets/clip.svg?react"; + +export function AttachImageLabel() { + return ( +
+ + Attach images +
+ ); +} diff --git a/frontend/src/components/chat-input.tsx b/frontend/src/components/chat-input.tsx new file mode 100644 index 0000000000..0aa4348e15 --- /dev/null +++ b/frontend/src/components/chat-input.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import ArrowSendIcon from "#/assets/arrow-send.svg?react"; +import { cn } from "#/utils/utils"; + +interface ChatInputProps { + name?: string; + button?: "submit" | "stop"; + disabled?: boolean; + placeholder?: string; + showButton?: boolean; + value?: string; + maxRows?: number; + onSubmit: (message: string) => void; + onStop?: () => void; + onChange?: (message: string) => void; + onFocus?: () => void; + onBlur?: () => void; + className?: React.HTMLAttributes["className"]; +} + +export function ChatInput({ + name, + button = "submit", + disabled, + placeholder, + showButton = true, + value, + maxRows = 4, + onSubmit, + onStop, + onChange, + onFocus, + onBlur, + className, +}: ChatInputProps) { + const textareaRef = React.useRef(null); + + const handleSubmitMessage = () => { + if (textareaRef.current?.value) { + onSubmit(textareaRef.current.value); + textareaRef.current.value = ""; + } + }; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmitMessage(); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + onChange?.(event.target.value); + }; + + return ( +
+ + {showButton && ( + <> + {button === "submit" && ( + + )} + {button === "stop" && ( + + )} + + )} +
+ ); +} diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx deleted file mode 100644 index a228f7fff7..0000000000 --- a/frontend/src/components/chat/ChatInput.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Textarea } from "@nextui-org/react"; -import React from "react"; -import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import { I18nKey } from "#/i18n/declaration"; -import Clip from "#/assets/clip.svg?react"; -import { RootState } from "#/store"; -import AgentState from "#/types/AgentState"; -import { useSocket } from "#/context/socket"; -import { generateAgentStateChangeEvent } from "#/services/agentStateService"; -import { cn } from "#/utils/utils"; -import ArrowSendIcon from "#/assets/arrow-send.svg?react"; -import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; - -interface ChatInputProps { - disabled?: boolean; - onSendMessage: (message: string, image_urls: string[]) => void; -} - -function ChatInput({ disabled = false, onSendMessage }: ChatInputProps) { - const { send } = useSocket(); - const { t } = useTranslation(); - const { curAgentState } = useSelector((state: RootState) => state.agent); - - const [message, setMessage] = React.useState(""); - const [files, setFiles] = React.useState([]); - // This is true when the user is typing in an IME (e.g., Chinese, Japanese) - const [isComposing, setIsComposing] = React.useState(false); - - const handleSendChatMessage = async () => { - if (curAgentState === AgentState.RUNNING) { - send(generateAgentStateChangeEvent(AgentState.STOPPED)); - return; - } - - if (message.trim()) { - let base64images: string[] = []; - if (files.length > 0) { - base64images = await Promise.all( - files.map((file) => convertImageToBase64(file)), - ); - } - onSendMessage(message, base64images); - setMessage(""); - setFiles([]); - } - }; - - const onKeyPress = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey && !isComposing) { - event.preventDefault(); // prevent a new line - if (!disabled) { - handleSendChatMessage(); - } - } - }; - - const handleFileChange = (event: React.ChangeEvent) => { - if (event.target.files) { - setFiles((prev) => [...prev, ...Array.from(event.target.files!)]); - } - }; - - const removeFile = (index: number) => { - setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); - }; - - const handlePaste = (event: React.ClipboardEvent) => { - const clipboardItems = Array.from(event.clipboardData.items); - const pastedFiles: File[] = []; - clipboardItems.forEach((item) => { - if (item.type.startsWith("image/")) { - const file = item.getAsFile(); - if (file) { - pastedFiles.push(file); - } - } - }); - if (pastedFiles.length > 0) { - setFiles((prevFiles) => [...prevFiles, ...pastedFiles]); - event.preventDefault(); - } - }; - - return ( -
-