diff --git a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx new file mode 100644 index 0000000000..57c2aea372 --- /dev/null +++ b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, test, vi } from "vitest"; +import { AccountSettingsContextMenu } from "#/components/context-menu/account-settings-context-menu"; + +describe("AccountSettingsContextMenu", () => { + const user = userEvent.setup(); + const onClickAccountSettingsMock = vi.fn(); + const onLogoutMock = vi.fn(); + const onCloseMock = vi.fn(); + + afterEach(() => { + onClickAccountSettingsMock.mockClear(); + onLogoutMock.mockClear(); + onCloseMock.mockClear(); + }); + + it("should always render the right options", () => { + render( + , + ); + + expect( + screen.getByTestId("account-settings-context-menu"), + ).toBeInTheDocument(); + expect(screen.getByText("Account Settings")).toBeInTheDocument(); + expect(screen.getByText("Logout")).toBeInTheDocument(); + }); + + it("should call onClickAccountSettings when the account settings option is clicked", async () => { + render( + , + ); + + const accountSettingsOption = screen.getByText("Account Settings"); + await user.click(accountSettingsOption); + + expect(onClickAccountSettingsMock).toHaveBeenCalledOnce(); + }); + + it("should call onLogout when the logout option is clicked", async () => { + render( + , + ); + + const logoutOption = screen.getByText("Logout"); + await user.click(logoutOption); + + expect(onLogoutMock).toHaveBeenCalledOnce(); + }); + + test("onLogout should be disabled if the user is not logged in", async () => { + render( + , + ); + + const logoutOption = screen.getByText("Logout"); + await user.click(logoutOption); + + expect(onLogoutMock).not.toHaveBeenCalled(); + }); + + it("should call onClose when clicking outside of the element", async () => { + render( + , + ); + + const accountSettingsButton = screen.getByText("Account Settings"); + await user.click(accountSettingsButton); + await user.click(document.body); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx b/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx new file mode 100644 index 0000000000..9f72aada2a --- /dev/null +++ b/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ContextMenuListItem } from "#/components/context-menu/context-menu-list-item"; + +describe("ContextMenuListItem", () => { + it("should render the component with the children", () => { + render(Test); + + expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument(); + expect(screen.getByText("Test")).toBeInTheDocument(); + }); + + it("should call the onClick callback when clicked", async () => { + const user = userEvent.setup(); + const onClickMock = vi.fn(); + render( + Test, + ); + + const element = screen.getByTestId("context-menu-list-item"); + await user.click(element); + + expect(onClickMock).toHaveBeenCalledOnce(); + }); + + it("should not call the onClick callback when clicked and the button is disabled", async () => { + const user = userEvent.setup(); + const onClickMock = vi.fn(); + render( + + Test + , + ); + + const element = screen.getByTestId("context-menu-list-item"); + await user.click(element); + + expect(onClickMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/components/settings/ai-config-form.test.tsx b/frontend/__tests__/components/settings/ai-config-form.test.tsx new file mode 100644 index 0000000000..24cb95bf91 --- /dev/null +++ b/frontend/__tests__/components/settings/ai-config-form.test.tsx @@ -0,0 +1,9 @@ +import { describe, it } from "vitest"; + +describe("AIConfigForm", () => { + it.todo("should render the AI config form"); + it.todo("should toggle the advanced settings when clicked"); + it.todo("should call the onSubmit callback when the form is submitted"); + it.todo("should call the onReset callback when the reset button is clicked"); + it.todo("should call the onClose callback when the close button is clicked"); +}); diff --git a/frontend/__tests__/components/settings/dropdown-input.test.tsx b/frontend/__tests__/components/settings/dropdown-input.test.tsx new file mode 100644 index 0000000000..0ad6455e85 --- /dev/null +++ b/frontend/__tests__/components/settings/dropdown-input.test.tsx @@ -0,0 +1,9 @@ +import { describe, it } from "vitest"; + +describe("DropdownInput", () => { + it.todo("should render the input"); + it.todo("should render the placeholder"); + it.todo("should render the dropdown when clicked"); + it.todo("should select an option when clicked"); + it.todo("should filter the options when typing"); +}); diff --git a/frontend/__tests__/components/settings/model-selector.test.tsx b/frontend/__tests__/components/settings/model-selector.test.tsx new file mode 100644 index 0000000000..dba8df580b --- /dev/null +++ b/frontend/__tests__/components/settings/model-selector.test.tsx @@ -0,0 +1,12 @@ +import { describe, it } from "vitest"; + +describe("ModelSelector", () => { + it.todo("should render the model selector"); + it.todo("should display and select the providers"); + it.todo("should display and select the models"); + it.todo("should disable the models if a provider is not selected"); + it.todo("should disable the inputs if isDisabled is true"); + it.todo( + "should set the selected model and provider if the currentModel prop is set", + ); +}); diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx new file mode 100644 index 0000000000..6186562ab9 --- /dev/null +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, test, vi, afterEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import * as Remix from "@remix-run/react"; +import { UserActions } from "#/components/user-actions"; + +describe("UserActions", () => { + const user = userEvent.setup(); + const onClickAccountSettingsMock = vi.fn(); + const onLogoutMock = vi.fn(); + + const useFetcherSpy = vi.spyOn(Remix, "useFetcher"); + // @ts-expect-error - Only returning the relevant properties for the test + useFetcherSpy.mockReturnValue({ state: "idle" }); + + afterEach(() => { + onClickAccountSettingsMock.mockClear(); + onLogoutMock.mockClear(); + useFetcherSpy.mockClear(); + }); + + it("should render", () => { + render( + , + ); + + expect(screen.getByTestId("user-actions")).toBeInTheDocument(); + expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); + }); + + it("should toggle the user menu when the user avatar is clicked", async () => { + render( + , + ); + + const userAvatar = screen.getByTestId("user-avatar"); + await user.click(userAvatar); + + expect( + screen.getByTestId("account-settings-context-menu"), + ).toBeInTheDocument(); + + await user.click(userAvatar); + + expect( + screen.queryByTestId("account-settings-context-menu"), + ).not.toBeInTheDocument(); + }); + + it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => { + render( + , + ); + + const userAvatar = screen.getByTestId("user-avatar"); + await user.click(userAvatar); + + const accountSettingsOption = screen.getByText("Account Settings"); + await user.click(accountSettingsOption); + + expect(onClickAccountSettingsMock).toHaveBeenCalledOnce(); + expect( + screen.queryByTestId("account-settings-context-menu"), + ).not.toBeInTheDocument(); + }); + + it("should call onLogout and close the menu when the logout option is clicked", async () => { + render( + , + ); + + const userAvatar = screen.getByTestId("user-avatar"); + await user.click(userAvatar); + + const logoutOption = screen.getByText("Logout"); + await user.click(logoutOption); + + expect(onLogoutMock).toHaveBeenCalledOnce(); + expect( + screen.queryByTestId("account-settings-context-menu"), + ).not.toBeInTheDocument(); + }); + + test("onLogout should not be called when the user is not logged in", async () => { + render( + , + ); + + const userAvatar = screen.getByTestId("user-avatar"); + await user.click(userAvatar); + + const logoutOption = screen.getByText("Logout"); + await user.click(logoutOption); + + expect(onLogoutMock).not.toHaveBeenCalled(); + }); + + it("should display the loading spinner", () => { + // @ts-expect-error - Only returning the relevant properties for the test + useFetcherSpy.mockReturnValue({ state: "loading" }); + + render( + , + ); + + const userAvatar = screen.getByTestId("user-avatar"); + user.click(userAvatar); + + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/user-avatar.test.tsx b/frontend/__tests__/components/user-avatar.test.tsx new file mode 100644 index 0000000000..07f3d44afe --- /dev/null +++ b/frontend/__tests__/components/user-avatar.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { UserAvatar } from "#/components/user-avatar"; + +describe("UserAvatar", () => { + const onClickMock = vi.fn(); + + afterEach(() => { + onClickMock.mockClear(); + }); + + it("(default) should render the placeholder avatar when the user is logged out", () => { + render(); + expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); + expect( + screen.getByLabelText("user avatar placeholder"), + ).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const user = userEvent.setup(); + render(); + + const userAvatarContainer = screen.getByTestId("user-avatar"); + await user.click(userAvatarContainer); + + expect(onClickMock).toHaveBeenCalledOnce(); + }); + + it("should display the user's avatar when available", () => { + render( + , + ); + + expect(screen.getByAltText("user avatar")).toBeInTheDocument(); + expect( + screen.queryByLabelText("user avatar placeholder"), + ).not.toBeInTheDocument(); + }); + + it("should display a loading spinner instead of an avatar when isLoading is true", () => { + const { rerender } = render(); + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + expect( + screen.getByLabelText("user avatar placeholder"), + ).toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + expect( + screen.queryByLabelText("user avatar placeholder"), + ).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/hooks/use-click-outside-element.test.tsx b/frontend/__tests__/hooks/use-click-outside-element.test.tsx new file mode 100644 index 0000000000..3097b5771a --- /dev/null +++ b/frontend/__tests__/hooks/use-click-outside-element.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, test, vi } from "vitest"; +import { useClickOutsideElement } from "#/hooks/useClickOutsideElement"; + +interface ClickOutsideTestComponentProps { + callback: () => void; +} + +function ClickOutsideTestComponent({ + callback, +}: ClickOutsideTestComponentProps) { + const ref = useClickOutsideElement(callback); + + return ( +
+
+
+
+ ); +} + +test("call the callback when the element is clicked outside", async () => { + const user = userEvent.setup(); + const callback = vi.fn(); + render(); + + const insideElement = screen.getByTestId("inside-element"); + const outsideElement = screen.getByTestId("outside-element"); + + await user.click(insideElement); + expect(callback).not.toHaveBeenCalled(); + + await user.click(outsideElement); + expect(callback).toHaveBeenCalled(); +}); diff --git a/frontend/__tests__/routes/_oh.test.tsx b/frontend/__tests__/routes/_oh.test.tsx new file mode 100644 index 0000000000..95b027d931 --- /dev/null +++ b/frontend/__tests__/routes/_oh.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, test } from "vitest"; + +describe("frontend/routes/_oh", () => { + describe("brand logo", () => { + it.todo("should not do anything if the user is in the main screen"); + it.todo( + "should be clickable and redirect to the main screen if the user is not in the main screen", + ); + }); + + describe("user menu", () => { + it.todo("should open the user menu when clicked"); + + describe("logged out", () => { + it.todo("should display a placeholder"); + test.todo("the logout option in the user menu should be disabled"); + }); + + describe("logged in", () => { + it.todo("should display the user's avatar"); + it.todo("should log the user out when the logout option is clicked"); + }); + }); + + describe("config", () => { + it.todo("should open the config modal when clicked"); + it.todo( + "should not save the config and close the config modal when the close button is clicked", + ); + it.todo( + "should save the config when the save button is clicked and close the modal", + ); + it.todo("should warn the user about saving the config when in /app"); + }); +}); diff --git a/frontend/src/components/account-settings-context-menu.tsx b/frontend/src/components/context-menu/account-settings-context-menu.tsx similarity index 57% rename from frontend/src/components/account-settings-context-menu.tsx rename to frontend/src/components/context-menu/account-settings-context-menu.tsx index 2fd780acc2..0d184a2a27 100644 --- a/frontend/src/components/account-settings-context-menu.tsx +++ b/frontend/src/components/context-menu/account-settings-context-menu.tsx @@ -1,30 +1,34 @@ -import { ContextMenu } from "./context-menu/context-menu"; -import { ContextMenuListItem } from "./context-menu/context-menu-list-item"; -import { ContextMenuSeparator } from "./context-menu/context-menu-separator"; +import { ContextMenu } from "./context-menu"; +import { ContextMenuListItem } from "./context-menu-list-item"; +import { ContextMenuSeparator } from "./context-menu-separator"; import { useClickOutsideElement } from "#/hooks/useClickOutsideElement"; interface AccountSettingsContextMenuProps { - isLoggedIn: boolean; onClickAccountSettings: () => void; onLogout: () => void; onClose: () => void; + isLoggedIn: boolean; } export function AccountSettingsContextMenu({ - isLoggedIn, onClickAccountSettings, onLogout, onClose, + isLoggedIn, }: AccountSettingsContextMenuProps) { - const menuRef = useClickOutsideElement(onClose); + const ref = useClickOutsideElement(onClose); return ( - + Account Settings - + Logout diff --git a/frontend/src/components/context-menu/context-menu-list-item.tsx b/frontend/src/components/context-menu/context-menu-list-item.tsx index cf3dcba12a..606090229c 100644 --- a/frontend/src/components/context-menu/context-menu-list-item.tsx +++ b/frontend/src/components/context-menu/context-menu-list-item.tsx @@ -1,25 +1,25 @@ import { cn } from "#/utils/utils"; interface ContextMenuListItemProps { - children: React.ReactNode; - onClick?: () => void; - disabled?: boolean; + onClick: () => void; + isDisabled?: boolean; } export function ContextMenuListItem({ children, onClick, - disabled, -}: ContextMenuListItemProps) { + isDisabled, +}: React.PropsWithChildren) { return ( diff --git a/frontend/src/components/context-menu/context-menu.tsx b/frontend/src/components/context-menu/context-menu.tsx index 385e1a7bc3..646eded39c 100644 --- a/frontend/src/components/context-menu/context-menu.tsx +++ b/frontend/src/components/context-menu/context-menu.tsx @@ -2,13 +2,15 @@ import React from "react"; import { cn } from "#/utils/utils"; interface ContextMenuProps { + testId?: string; children: React.ReactNode; className?: React.HTMLAttributes["className"]; } export const ContextMenu = React.forwardRef( - ({ children, className }, ref) => ( + ({ testId, children, className }, ref) => (
    diff --git a/frontend/src/components/modals/LoadingProject.tsx b/frontend/src/components/modals/LoadingProject.tsx index 7814a6fe33..3e45882a7c 100644 --- a/frontend/src/components/modals/LoadingProject.tsx +++ b/frontend/src/components/modals/LoadingProject.tsx @@ -11,7 +11,7 @@ export function LoadingSpinner({ size }: LoadingSpinnerProps) { size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]"; return ( -
    +
    void; + onLogout: () => void; + user?: { avatar_url: string }; +} + +export function UserActions({ + onClickAccountSettings, + onLogout, + user, +}: UserActionsProps) { + const loginFetcher = useFetcher({ key: "login" }); + + const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = + React.useState(false); + + const toggleAccountMenu = () => { + setAccountContextMenuIsVisible((prev) => !prev); + }; + + const closeAccountMenu = () => { + setAccountContextMenuIsVisible(false); + }; + + const handleClickAccountSettings = () => { + onClickAccountSettings(); + closeAccountMenu(); + }; + + const handleLogout = () => { + onLogout(); + closeAccountMenu(); + }; + + return ( +
    + + + {accountContextMenuIsVisible && ( + + )} +
    + ); +} diff --git a/frontend/src/components/user-avatar.tsx b/frontend/src/components/user-avatar.tsx index 69d6e55709..88f09caf93 100644 --- a/frontend/src/components/user-avatar.tsx +++ b/frontend/src/components/user-avatar.tsx @@ -1,68 +1,39 @@ -import { cn } from "@nextui-org/react"; -import React from "react"; -import { isGitHubErrorReponse } from "#/api/github"; -import { AccountSettingsContextMenu } from "./account-settings-context-menu"; import { LoadingSpinner } from "./modals/LoadingProject"; import DefaultUserAvatar from "#/assets/default-user.svg?react"; +import { cn } from "#/utils/utils"; interface UserAvatarProps { - isLoading: boolean; - user: GitHubUser | GitHubErrorReponse | null; - onLogout: () => void; - handleOpenAccountSettingsModal: () => void; + onClick: () => void; + avatarUrl?: string; + isLoading?: boolean; } -export function UserAvatar({ - isLoading, - user, - onLogout, - handleOpenAccountSettingsModal, -}: UserAvatarProps) { - const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = - React.useState(false); - - const validUser = user && !isGitHubErrorReponse(user); - - const handleClickUserAvatar = () => { - setAccountContextMenuIsVisible((prev) => !prev); - }; - +export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { return ( -
    - - {accountContextMenuIsVisible && ( - setAccountContextMenuIsVisible(false)} - onClickAccountSettings={() => { - setAccountContextMenuIsVisible(false); - handleOpenAccountSettingsModal(); - }} - onLogout={() => { - onLogout(); - setAccountContextMenuIsVisible(false); - }} +
    + {!isLoading && !avatarUrl && ( + + )} + {isLoading && } + ); } diff --git a/frontend/src/routes/_oh.tsx b/frontend/src/routes/_oh.tsx index 573cb0dc5f..2d2597f2a4 100644 --- a/frontend/src/routes/_oh.tsx +++ b/frontend/src/routes/_oh.tsx @@ -17,7 +17,7 @@ import AccountSettingsModal from "#/components/modals/AccountSettingsModal"; import { DangerModal } from "#/components/modals/confirmation-modals/danger-modal"; import { LoadingSpinner } from "#/components/modals/LoadingProject"; import { ModalBackdrop } from "#/components/modals/modal-backdrop"; -import { UserAvatar } from "#/components/user-avatar"; +import { UserActions } from "#/components/user-actions"; import { useSocket } from "#/context/socket"; import i18n from "#/i18n"; import { getSettings, settingsAreUpToDate } from "#/services/settings"; @@ -97,7 +97,6 @@ export default function MainApp() { const location = useLocation(); const { token, user, settingsIsUpdated, settings } = useLoaderData(); - const loginFetcher = useFetcher({ key: "login" }); const logoutFetcher = useFetcher({ key: "logout" }); const endSessionFetcher = useFetcher({ key: "end-session" }); @@ -192,13 +191,14 @@ export default function MainApp() { )}