mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-10 15:28:14 -05:00
(test|refactor)(frontend): Refactor/cleanup and extend tests for ChatInterface, its children, and introduce improvements to the feedback flow (#2997)
* Refactor and remove useless test * Refactor and test feedback modal artifacts * Update and pass test * Replace select with radio buttons * Store and retrieve user email during feedback * Improve post share feedback toast * Fix tests * Add test todo
This commit is contained in:
@@ -9,16 +9,21 @@ import ActionType from "#/types/ActionType";
|
||||
import { addAssistantMessage } from "#/state/chatSlice";
|
||||
import AgentState from "#/types/AgentState";
|
||||
|
||||
const sessionSpy = vi.spyOn(Session, "send");
|
||||
vi.spyOn(Session, "isConnected").mockReturnValue(true);
|
||||
|
||||
// This is for the scrollview ref in Chat.tsx
|
||||
// TODO: Move this into test setup
|
||||
HTMLElement.prototype.scrollTo = vi.fn().mockImplementation(() => {});
|
||||
|
||||
describe("ChatInterface", () => {
|
||||
const sessionSendSpy = vi.spyOn(Session, "send");
|
||||
vi.spyOn(Session, "isConnected").mockReturnValue(true);
|
||||
|
||||
const userMessageEvent = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: "my message" },
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
sessionSpy.mockClear();
|
||||
sessionSendSpy.mockClear();
|
||||
});
|
||||
|
||||
it("should render empty message list and input", () => {
|
||||
@@ -26,21 +31,6 @@ describe("ChatInterface", () => {
|
||||
expect(screen.queryAllByTestId("message")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should render the new message the user has typed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
agent: {
|
||||
curAgentState: AgentState.INIT,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "my message");
|
||||
expect(input).toHaveValue("my message");
|
||||
});
|
||||
|
||||
it("should render user and assistant messages", () => {
|
||||
const { store } = renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
@@ -54,6 +44,7 @@ describe("ChatInterface", () => {
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
// simulate assistant response
|
||||
store.dispatch(addAssistantMessage("Hello to you!"));
|
||||
});
|
||||
|
||||
@@ -61,7 +52,7 @@ describe("ChatInterface", () => {
|
||||
expect(screen.getByText("Hello to you!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should send a start event to the Session", async () => {
|
||||
it("should send the user message as an event to the Session when the agent state is INIT", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
@@ -75,14 +66,12 @@ describe("ChatInterface", () => {
|
||||
await user.type(input, "my message");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
const event = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: "my message" },
|
||||
};
|
||||
expect(sessionSpy).toHaveBeenCalledWith(JSON.stringify(event));
|
||||
expect(sessionSendSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(userMessageEvent),
|
||||
);
|
||||
});
|
||||
|
||||
it("should send a user message event to the Session", async () => {
|
||||
it("should send the user message as an event to the Session when the agent state is AWAITING_USER_INPUT", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
@@ -96,14 +85,13 @@ describe("ChatInterface", () => {
|
||||
await user.type(input, "my message");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
const event = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: "my message" },
|
||||
};
|
||||
expect(sessionSpy).toHaveBeenCalledWith(JSON.stringify(event));
|
||||
expect(sessionSendSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(userMessageEvent),
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable the user input if agent is not initialized", () => {
|
||||
it("should disable the user input if agent is not initialized", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ChatInterface />, {
|
||||
preloadedState: {
|
||||
agent: {
|
||||
@@ -112,10 +100,16 @@ describe("ChatInterface", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "my message");
|
||||
await user.keyboard("{Enter}");
|
||||
const submitButton = screen.getByLabelText(
|
||||
"CHAT_INTERFACE$TOOLTIP_SEND_MESSAGE",
|
||||
);
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(sessionSendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.todo("test scroll-related behaviour");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// frontend/src/components/chat/ChatInterface.tsx
|
||||
import React, { useRef } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { IoMdChatbubbles } from "react-icons/io";
|
||||
@@ -16,11 +15,7 @@ import { sendChatMessage } from "#/services/chatService";
|
||||
import { addUserMessage, addAssistantMessage } from "#/state/chatSlice";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useScrollToBottom } from "#/hooks/useScrollToBottom";
|
||||
import { Feedback } from "#/services/feedbackService";
|
||||
import FeedbackModal from "../modals/feedback/FeedbackModal";
|
||||
import { removeApiKey } from "#/utils/utils";
|
||||
import Session from "#/services/session";
|
||||
import { getToken } from "#/services/auth";
|
||||
|
||||
interface ScrollButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -55,15 +50,9 @@ function ChatInterface() {
|
||||
const { messages } = useSelector((state: RootState) => state.chat);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const feedbackVersion = "1.0";
|
||||
const [feedback, setFeedback] = React.useState<Feedback>({
|
||||
email: "",
|
||||
feedback: "positive",
|
||||
permissions: "private",
|
||||
trajectory: [],
|
||||
token: "",
|
||||
version: feedbackVersion,
|
||||
});
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
>("positive");
|
||||
const [feedbackShared, setFeedbackShared] = React.useState(0);
|
||||
|
||||
const {
|
||||
@@ -73,13 +62,8 @@ function ChatInterface() {
|
||||
} = useDisclosure();
|
||||
|
||||
const shareFeedback = async (polarity: "positive" | "negative") => {
|
||||
setFeedback((prev) => ({
|
||||
...prev,
|
||||
feedback: polarity,
|
||||
trajectory: removeApiKey(Session._history),
|
||||
token: getToken(),
|
||||
}));
|
||||
onFeedbackModalOpen();
|
||||
setFeedbackPolarity(polarity);
|
||||
};
|
||||
|
||||
const handleSendMessage = (content: string) => {
|
||||
@@ -87,14 +71,6 @@ function ChatInterface() {
|
||||
sendChatMessage(content);
|
||||
};
|
||||
|
||||
const handleEmailChange = (key: string) => {
|
||||
setFeedback({ ...feedback, email: key } as Feedback);
|
||||
};
|
||||
|
||||
const handlePermissionsChange = (permissions: "public" | "private") => {
|
||||
setFeedback({ ...feedback, permissions } as Feedback);
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
const handleSendContinueMsg = () => {
|
||||
handleSendMessage(t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE));
|
||||
@@ -176,9 +152,7 @@ function ChatInterface() {
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
<FeedbackModal
|
||||
feedback={feedback}
|
||||
handleEmailChange={handleEmailChange}
|
||||
handlePermissionsChange={handlePermissionsChange}
|
||||
polarity={feedbackPolarity}
|
||||
isOpen={feedbackModalIsOpen}
|
||||
onOpenChange={onFeedbackModalOpenChange}
|
||||
onSendFeedback={() => setFeedbackShared(messages.length)}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface BaseModalProps {
|
||||
subtitle?: string;
|
||||
actions?: Action[];
|
||||
children?: React.ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function BaseModal({
|
||||
@@ -27,9 +28,11 @@ function BaseModal({
|
||||
subtitle = undefined,
|
||||
actions = [],
|
||||
children = null,
|
||||
testID,
|
||||
}: BaseModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
data-testid={testID}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Input, Select, SelectItem } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "../../../i18n/declaration";
|
||||
import { Feedback } from "#/services/feedbackService";
|
||||
|
||||
interface FeedbackFormProps {
|
||||
feedback: Feedback;
|
||||
|
||||
onEmailChange: (email: string) => void;
|
||||
onPermissionsChange: (permissions: "public" | "private") => void;
|
||||
}
|
||||
|
||||
function FeedbackForm({
|
||||
feedback,
|
||||
onEmailChange,
|
||||
onPermissionsChange,
|
||||
}: FeedbackFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEmailValid = (email: string) => {
|
||||
// Regular expression to validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label="Email"
|
||||
aria-label="email"
|
||||
data-testid="email"
|
||||
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
|
||||
type="text"
|
||||
value={feedback.email || ""}
|
||||
onChange={(e) => {
|
||||
onEmailChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Sharing settings"
|
||||
aria-label="permissions"
|
||||
data-testid="permissions"
|
||||
value={feedback.permissions}
|
||||
onChange={(e) => {
|
||||
onPermissionsChange(e.target.value as "public" | "private");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="public" value="public">
|
||||
Public
|
||||
</SelectItem>
|
||||
<SelectItem key="private" value="private">
|
||||
Private
|
||||
</SelectItem>
|
||||
</Select>
|
||||
{isEmailValid(feedback.email) ? null : (
|
||||
<p className="text-red-500">Invalid email format</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackForm;
|
||||
194
frontend/src/components/modals/feedback/FeedbackModal.test.tsx
Normal file
194
frontend/src/components/modals/feedback/FeedbackModal.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { Mock, describe } from "vitest";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import FeedbackModal from "./FeedbackModal";
|
||||
import { sendFeedback } from "#/services/feedbackService";
|
||||
|
||||
describe("FeedbackModal", () => {
|
||||
Storage.prototype.setItem = vi.fn();
|
||||
Storage.prototype.getItem = vi.fn();
|
||||
|
||||
vi.mock("#/services/feedbackService", () => ({
|
||||
sendFeedback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/services/auth", () => ({
|
||||
getToken: vi.fn().mockReturnValue("some-token"),
|
||||
}));
|
||||
// mock Session class
|
||||
vi.mock("#/services/session", () => ({
|
||||
default: {
|
||||
_history: [
|
||||
{ args: { LLM_API_KEY: "DANGER-key-should-not-be-here" } },
|
||||
{ content: "Hello" },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the feedback model when open", () => {
|
||||
const { rerender } = render(
|
||||
<FeedbackModal
|
||||
polarity="positive"
|
||||
isOpen={false}
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("feedback-modal")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<FeedbackModal
|
||||
polarity="positive"
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("feedback-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display an error if the email is invalid when submitting", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FeedbackModal
|
||||
polarity="positive"
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "FEEDBACK$SHARE_LABEL",
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.getByTestId("invalid-email-message")).toBeInTheDocument();
|
||||
expect(sendFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call sendFeedback with the correct data when the share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FeedbackModal
|
||||
polarity="negative"
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "FEEDBACK$SHARE_LABEL",
|
||||
});
|
||||
|
||||
const email = "example@example.com";
|
||||
const emailInput = screen.getByTestId("email-input");
|
||||
await user.type(emailInput, email);
|
||||
|
||||
// select public
|
||||
const permissionsGroup = screen.getByTestId("permissions-group");
|
||||
const publicOption = within(permissionsGroup).getByRole("radio", {
|
||||
name: "FEEDBACK$PUBLIC_LABEL",
|
||||
});
|
||||
expect(publicOption).not.toBeChecked();
|
||||
await user.click(publicOption);
|
||||
expect(publicOption).toBeChecked();
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("invalid-email-message"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(sendFeedback).toHaveBeenCalledWith({
|
||||
email,
|
||||
permissions: "public",
|
||||
feedback: "negative",
|
||||
trajectory: [{ args: {} }, { content: "Hello" }], // api key should be removed
|
||||
token: "some-token",
|
||||
version: "1.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should store the users email in local state for later use", async () => {
|
||||
const email = "example@example.com";
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<FeedbackModal
|
||||
polarity="negative"
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith("feedback-email");
|
||||
const emailInput = screen.getByTestId("email-input");
|
||||
expect(emailInput).toHaveValue("");
|
||||
|
||||
await user.type(emailInput, email);
|
||||
expect(emailInput).toHaveValue(email);
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "FEEDBACK$SHARE_LABEL",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith("feedback-email", email);
|
||||
|
||||
rerender(
|
||||
<FeedbackModal
|
||||
polarity="positive"
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputAfterClose = screen.getByTestId("email-input");
|
||||
expect(emailInputAfterClose).toHaveValue(email);
|
||||
});
|
||||
|
||||
// TODO: figure out how to properly mock toast
|
||||
it.skip("should display a success toast when the feedback is shared successfully", async () => {
|
||||
(sendFeedback as Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: "Feedback shared",
|
||||
feedback_id: "some-id",
|
||||
password: "some-password",
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FeedbackModal
|
||||
polarity="negative"
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
onSendFeedback={vi.fn}
|
||||
/>,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "FEEDBACK$SHARE_LABEL",
|
||||
});
|
||||
|
||||
const email = "example@example.com";
|
||||
const emailInput = screen.getByTestId("email-input");
|
||||
await user.type(emailInput, email);
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(toast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,127 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input, Radio, RadioGroup } from "@nextui-org/react";
|
||||
import hotToast from "react-hot-toast";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import { Feedback, sendFeedback } from "#/services/feedbackService";
|
||||
import FeedbackForm from "./FeedbackForm";
|
||||
import toast from "#/utils/toast";
|
||||
import { getToken } from "#/services/auth";
|
||||
import Session from "#/services/session";
|
||||
import { removeApiKey } from "#/utils/utils";
|
||||
|
||||
const isEmailValid = (email: string) => {
|
||||
// Regular expression to validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const VIEWER_PAGE = "https://www.all-hands.dev/share-opendevin";
|
||||
const FEEDBACK_VERSION = "1.0";
|
||||
|
||||
interface FeedbackModalProps {
|
||||
feedback: Feedback;
|
||||
handleEmailChange: (key: string) => void;
|
||||
handlePermissionsChange: (permissions: "public" | "private") => void;
|
||||
polarity: "positive" | "negative";
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onSendFeedback: () => void;
|
||||
}
|
||||
|
||||
function FeedbackModal({
|
||||
feedback,
|
||||
handleEmailChange,
|
||||
handlePermissionsChange,
|
||||
polarity,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onSendFeedback,
|
||||
}: FeedbackModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSendFeedback = () => {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [permissions, setPermissions] = React.useState<"public" | "private">(
|
||||
"private",
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// check if email is stored in local storage
|
||||
const storedEmail = localStorage.getItem("feedback-email");
|
||||
if (storedEmail) setEmail(storedEmail);
|
||||
}, []);
|
||||
|
||||
const handleEmailChange = (newEmail: string) => {
|
||||
setEmail(newEmail);
|
||||
};
|
||||
|
||||
const copiedToClipboardToast = () => {
|
||||
hotToast("Password copied to clipboard", {
|
||||
icon: "📋",
|
||||
position: "bottom-right",
|
||||
});
|
||||
};
|
||||
|
||||
const onPressToast = (password: string) => {
|
||||
navigator.clipboard.writeText(password);
|
||||
copiedToClipboardToast();
|
||||
};
|
||||
|
||||
const shareFeedbackToast = (
|
||||
message: string,
|
||||
link: string,
|
||||
password: string,
|
||||
) => {
|
||||
hotToast(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{message}</span>
|
||||
<a
|
||||
data-testid="toast-share-url"
|
||||
className="text-blue-500 underline"
|
||||
onClick={() => onPressToast(password)}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Go to shared feedback
|
||||
</a>
|
||||
<span onClick={() => onPressToast(password)} className="cursor-pointer">
|
||||
Password: {password} <span className="text-gray-500">(copy)</span>
|
||||
</span>
|
||||
</div>,
|
||||
{ duration: 5000 },
|
||||
);
|
||||
};
|
||||
|
||||
const handleSendFeedback = async () => {
|
||||
onSendFeedback();
|
||||
sendFeedback(feedback)
|
||||
.then((response) => {
|
||||
if (response.statusCode === 200) {
|
||||
const { message, feedback_id: feedbackId, password } = response.body;
|
||||
const toastMessage = `${message}\nFeedback link: ${VIEWER_PAGE}?share_id=${feedbackId}\nPassword: ${password}`;
|
||||
toast.info(toastMessage);
|
||||
} else {
|
||||
toast.error(
|
||||
"share-error",
|
||||
`Failed to share, please contact the developers: ${response.body.message}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const feedback: Feedback = {
|
||||
version: FEEDBACK_VERSION,
|
||||
feedback: polarity,
|
||||
email,
|
||||
permissions,
|
||||
token: getToken(),
|
||||
trajectory: removeApiKey(Session._history),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await sendFeedback(feedback);
|
||||
localStorage.setItem("feedback-email", email); // store email in local storage
|
||||
if (response.statusCode === 200) {
|
||||
const { message, feedback_id: feedbackId, password } = response.body;
|
||||
const link = `${VIEWER_PAGE}?share_id=${feedbackId}&password=${password}`;
|
||||
shareFeedbackToast(message, link, password);
|
||||
} else {
|
||||
toast.error(
|
||||
"share-error",
|
||||
`Failed to share, please contact the developers: ${error}`,
|
||||
`Failed to share, please contact the developers: ${response.body.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
"share-error",
|
||||
`Failed to share, please contact the developers: ${error}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
testID="feedback-modal"
|
||||
isOpen={isOpen}
|
||||
title={t(I18nKey.FEEDBACK$MODAL_TITLE)}
|
||||
onOpenChange={onOpenChange}
|
||||
@@ -61,6 +131,7 @@ function FeedbackModal({
|
||||
label: t(I18nKey.FEEDBACK$SHARE_LABEL),
|
||||
className: "bg-primary rounded-lg",
|
||||
action: handleSendFeedback,
|
||||
isDisabled: !isEmailValid(email),
|
||||
closeAfterAction: true,
|
||||
},
|
||||
{
|
||||
@@ -72,11 +143,33 @@ function FeedbackModal({
|
||||
]}
|
||||
>
|
||||
<p>{t(I18nKey.FEEDBACK$MODAL_CONTENT)}</p>
|
||||
<FeedbackForm
|
||||
feedback={feedback}
|
||||
onEmailChange={handleEmailChange}
|
||||
onPermissionsChange={handlePermissionsChange}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
aria-label="email"
|
||||
data-testid="email-input"
|
||||
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
handleEmailChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{!isEmailValid(email) && (
|
||||
<p data-testid="invalid-email-message" className="text-red-500">
|
||||
Invalid email format
|
||||
</p>
|
||||
)}
|
||||
<RadioGroup
|
||||
data-testid="permissions-group"
|
||||
label="Sharing settings"
|
||||
orientation="horizontal"
|
||||
value={permissions}
|
||||
onValueChange={(value) => setPermissions(value as "public" | "private")}
|
||||
>
|
||||
<Radio value="private">{t(I18nKey.FEEDBACK$PRIVATE_LABEL)}</Radio>
|
||||
<Radio value="public">{t(I18nKey.FEEDBACK$PUBLIC_LABEL)}</Radio>
|
||||
</RadioGroup>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, act, waitFor } from "@testing-library/react";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import React from "react";
|
||||
@@ -70,19 +70,15 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("should close the modal when the close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(<SettingsModal isOpen onOpenChange={onOpenChange} />),
|
||||
);
|
||||
renderWithProviders(<SettingsModal isOpen onOpenChange={onOpenChange} />);
|
||||
|
||||
const cancelButton = screen.getByRole("button", {
|
||||
name: /MODAL_CLOSE_BUTTON_LABEL/i, // i18n key
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
await user.click(cancelButton);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -113,11 +109,10 @@ describe("SettingsModal", () => {
|
||||
};
|
||||
|
||||
it("should save the settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChangeMock} />,
|
||||
),
|
||||
renderWithProviders(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChangeMock} />,
|
||||
);
|
||||
|
||||
// Use the helper function to assert models were fetched
|
||||
@@ -126,19 +121,11 @@ describe("SettingsModal", () => {
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
await user.click(modelInput);
|
||||
const model3 = screen.getByText("model3");
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(model3);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(saveButton);
|
||||
});
|
||||
await user.click(model3);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
...initialSettings,
|
||||
@@ -147,6 +134,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("should reinitialize agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
@@ -157,24 +145,17 @@ describe("SettingsModal", () => {
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
await user.click(modelInput);
|
||||
const model3 = screen.getByText("model3");
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(model3);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(saveButton);
|
||||
});
|
||||
await user.click(model3);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(startNewSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display a toast for every change", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
@@ -185,24 +166,17 @@ describe("SettingsModal", () => {
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
await user.click(modelInput);
|
||||
const model3 = screen.getByText("model3");
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(model3);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(saveButton);
|
||||
});
|
||||
await user.click(model3);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(toastSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should change the language", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
@@ -213,24 +187,17 @@ describe("SettingsModal", () => {
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(languageInput);
|
||||
});
|
||||
|
||||
await user.click(languageInput);
|
||||
const spanish = screen.getByText("Español");
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(spanish);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(saveButton);
|
||||
});
|
||||
await user.click(spanish);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(i18nSpy).toHaveBeenCalledWith("es");
|
||||
});
|
||||
|
||||
it("should close the modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
@@ -245,25 +212,18 @@ describe("SettingsModal", () => {
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(modelInput);
|
||||
});
|
||||
|
||||
await user.click(modelInput);
|
||||
const model3 = screen.getByText("model3");
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(model3);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(saveButton);
|
||||
});
|
||||
await user.click(model3);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset settings to defaults when the 'reset to defaults' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
@@ -276,18 +236,12 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(agentInput);
|
||||
});
|
||||
await user.click(agentInput);
|
||||
const agent3 = screen.getByText("agent3");
|
||||
await act(async () => {
|
||||
await userEvent.click(agent3);
|
||||
});
|
||||
await user.click(agent3);
|
||||
expect(agentInput).toHaveValue("agent3");
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(resetButton);
|
||||
});
|
||||
await user.click(resetButton);
|
||||
expect(getDefaultSettings).toHaveBeenCalled();
|
||||
|
||||
expect(agentInput).toHaveValue("CodeActAgent"); // Agent value is reset to default from getDefaultSettings()
|
||||
|
||||
@@ -732,5 +732,17 @@
|
||||
"zh-CN": "计划未创建",
|
||||
"zh-TW": "未創建任何計劃。",
|
||||
"de": "Kein Plan erstellt."
|
||||
},
|
||||
"FEEDBACK$PUBLIC_LABEL": {
|
||||
"en": "Public",
|
||||
"zh-CN": "公开",
|
||||
"zh-TW": "公開。",
|
||||
"de": "Öffentlich"
|
||||
},
|
||||
"FEEDBACK$PRIVATE_LABEL": {
|
||||
"en": "Private",
|
||||
"zh-CN": "私有",
|
||||
"zh-TW": "私有。",
|
||||
"de": "Privat"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user