test(frontend): User actions and friends (#4497)

This commit is contained in:
sp.wack
2024-10-22 20:04:07 +04:00
committed by GitHub
parent 54250e3fe2
commit 864f81bc71
16 changed files with 558 additions and 82 deletions

View File

@@ -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(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
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(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
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(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
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(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn={false}
/>,
);
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(
<AccountSettingsContextMenu
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
onClose={onCloseMock}
isLoggedIn
/>,
);
const accountSettingsButton = screen.getByText("Account Settings");
await user.click(accountSettingsButton);
await user.click(document.body);
expect(onCloseMock).toHaveBeenCalledOnce();
});
});

View File

@@ -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(<ContextMenuListItem onClick={vi.fn}>Test</ContextMenuListItem>);
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(
<ContextMenuListItem onClick={onClickMock}>Test</ContextMenuListItem>,
);
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(
<ContextMenuListItem onClick={onClickMock} isDisabled>
Test
</ContextMenuListItem>,
);
const element = screen.getByTestId("context-menu-list-item");
await user.click(element);
expect(onClickMock).not.toHaveBeenCalled();
});
});

View File

@@ -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");
});

View File

@@ -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");
});

View File

@@ -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",
);
});

View File

@@ -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(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
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(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
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(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
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(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
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(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
/>,
);
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(
<UserActions
onClickAccountSettings={onClickAccountSettingsMock}
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
user.click(userAvatar);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
});
});

View File

@@ -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(<UserAvatar onClick={onClickMock} />);
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(<UserAvatar onClick={onClickMock} />);
const userAvatarContainer = screen.getByTestId("user-avatar");
await user.click(userAvatarContainer);
expect(onClickMock).toHaveBeenCalledOnce();
});
it("should display the user's avatar when available", () => {
render(
<UserAvatar
onClick={onClickMock}
avatarUrl="https://example.com/avatar.png"
/>,
);
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(<UserAvatar onClick={onClickMock} />);
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
expect(
screen.getByLabelText("user avatar placeholder"),
).toBeInTheDocument();
rerender(<UserAvatar onClick={onClickMock} isLoading />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(
screen.queryByLabelText("user avatar placeholder"),
).not.toBeInTheDocument();
rerender(
<UserAvatar
onClick={onClickMock}
avatarUrl="https://example.com/avatar.png"
isLoading
/>,
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
});
});

View File

@@ -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<HTMLDivElement>(callback);
return (
<div>
<div data-testid="inside-element" ref={ref} />
<div data-testid="outside-element" />
</div>
);
}
test("call the callback when the element is clicked outside", async () => {
const user = userEvent.setup();
const callback = vi.fn();
render(<ClickOutsideTestComponent callback={callback} />);
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();
});

View File

@@ -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");
});
});

View File

@@ -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<HTMLUListElement>(onClose);
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
return (
<ContextMenu ref={menuRef} className="absolute left-full -top-1 z-10">
<ContextMenu
testId="account-settings-context-menu"
ref={ref}
className="absolute left-full -top-1 z-10"
>
<ContextMenuListItem onClick={onClickAccountSettings}>
Account Settings
</ContextMenuListItem>
<ContextMenuSeparator />
<ContextMenuListItem disabled={!isLoggedIn} onClick={onLogout}>
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
Logout
</ContextMenuListItem>
</ContextMenu>

View File

@@ -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<ContextMenuListItemProps>) {
return (
<button
data-testid="context-menu-list-item"
type="button"
disabled={disabled}
onClick={onClick}
disabled={isDisabled}
className={cn(
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent",
)}
onClick={onClick}
>
{children}
</button>

View File

@@ -2,13 +2,15 @@ import React from "react";
import { cn } from "#/utils/utils";
interface ContextMenuProps {
testId?: string;
children: React.ReactNode;
className?: React.HTMLAttributes<HTMLUListElement>["className"];
}
export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
({ children, className }, ref) => (
({ testId, children, className }, ref) => (
<ul
data-testid={testId}
ref={ref}
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
>

View File

@@ -11,7 +11,7 @@ export function LoadingSpinner({ size }: LoadingSpinnerProps) {
size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]";
return (
<div className={cn("relative", sizeStyle)}>
<div data-testid="loading-spinner" className={cn("relative", sizeStyle)}>
<div
className={cn(
"rounded-full border-4 border-[#525252] absolute",

View File

@@ -0,0 +1,58 @@
import React from "react";
import { useFetcher } from "@remix-run/react";
import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu";
import { UserAvatar } from "./user-avatar";
interface UserActionsProps {
onClickAccountSettings: () => 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 (
<div data-testid="user-actions" className="w-8 h-8 relative">
<UserAvatar
isLoading={loginFetcher.state !== "idle"}
avatarUrl={user?.avatar_url}
onClick={toggleAccountMenu}
/>
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu
isLoggedIn={!!user}
onClickAccountSettings={handleClickAccountSettings}
onLogout={handleLogout}
onClose={closeAccountMenu}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="w-8 h-8 relative">
<button
type="button"
className={cn(
"bg-white w-8 h-8 rounded-full flex items-center justify-center",
isLoading && "bg-transparent",
)}
onClick={handleClickUserAvatar}
>
{!validUser && !isLoading && (
<DefaultUserAvatar width={20} height={20} />
)}
{!validUser && isLoading && <LoadingSpinner size="small" />}
{validUser && (
<img
src={user.avatar_url}
alt="User avatar"
className="w-full h-full rounded-full"
/>
)}
</button>
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu
isLoggedIn={!!user}
onClose={() => setAccountContextMenuIsVisible(false)}
onClickAccountSettings={() => {
setAccountContextMenuIsVisible(false);
handleOpenAccountSettingsModal();
}}
onLogout={() => {
onLogout();
setAccountContextMenuIsVisible(false);
}}
<button
data-testid="user-avatar"
type="button"
onClick={onClick}
className={cn(
"bg-white w-8 h-8 rounded-full flex items-center justify-center",
isLoading && "bg-transparent",
)}
>
{!isLoading && avatarUrl && (
<img
src={avatarUrl}
alt="user avatar"
className="w-full h-full rounded-full"
/>
)}
</div>
{!isLoading && !avatarUrl && (
<DefaultUserAvatar
aria-label="user avatar placeholder"
width={20}
height={20}
/>
)}
{isLoading && <LoadingSpinner size="small" />}
</button>
);
}

View File

@@ -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<typeof clientLoader>();
const loginFetcher = useFetcher({ key: "login" });
const logoutFetcher = useFetcher({ key: "logout" });
const endSessionFetcher = useFetcher({ key: "end-session" });
@@ -192,13 +191,14 @@ export default function MainApp() {
)}
</div>
<nav className="py-[18px] flex flex-col items-center gap-[18px]">
<UserAvatar
user={user}
isLoading={loginFetcher.state !== "idle"}
onLogout={handleUserLogout}
handleOpenAccountSettingsModal={() =>
setAccountSettingsModalOpen(true)
<UserActions
user={
user && !isGitHubErrorReponse(user)
? { avatar_url: user.avatar_url }
: undefined
}
onLogout={handleUserLogout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
<button
type="button"