mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
test(frontend): User actions and friends (#4497)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
132
frontend/__tests__/components/user-actions.test.tsx
Normal file
132
frontend/__tests__/components/user-actions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
68
frontend/__tests__/components/user-avatar.test.tsx
Normal file
68
frontend/__tests__/components/user-avatar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
36
frontend/__tests__/hooks/use-click-outside-element.test.tsx
Normal file
36
frontend/__tests__/hooks/use-click-outside-element.test.tsx
Normal 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();
|
||||
});
|
||||
35
frontend/__tests__/routes/_oh.test.tsx
Normal file
35
frontend/__tests__/routes/_oh.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
58
frontend/src/components/user-actions.tsx
Normal file
58
frontend/src/components/user-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user