Przeglądaj źródła

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

sp.wack 1 rok temu
rodzic
commit
864f81bc71

+ 99 - 0
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(
+      <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();
+  });
+});

+ 41 - 0
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(<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();
+  });
+});

+ 9 - 0
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");
+});

+ 9 - 0
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");
+});

+ 12 - 0
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",
+  );
+});

+ 132 - 0
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(
+      <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 - 0
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(<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 - 0
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<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 - 0
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");
+  });
+});

+ 12 - 8
frontend/src/components/account-settings-context-menu.tsx → 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<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>

+ 7 - 7
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<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>

+ 3 - 1
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<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)}
     >

+ 1 - 1
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 (
-    <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 - 0
frontend/src/components/user-actions.tsx

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

+ 28 - 57
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 (
-    <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"
+        />
+      )}
+      {!isLoading && !avatarUrl && (
+        <DefaultUserAvatar
+          aria-label="user avatar placeholder"
+          width={20}
+          height={20}
         />
       )}
-    </div>
+      {isLoading && <LoadingSpinner size="small" />}
+    </button>
   );
 }

+ 8 - 8
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<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"