Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { Theme } from "@radix-ui/themes";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";

type AuthState = {
cloudRegion: string | null;
orgProjectsMap: Record<
string,
{ orgName: string; projects: { id: number; name: string }[] }
>;
currentOrgId: string | null;
};

let mockAuthState: AuthState = {
cloudRegion: "us",
orgProjectsMap: {},
currentOrgId: null,
};

const switchOrgMutate = vi.fn();
const selectProjectMutate = vi.fn();
const logoutMutate = vi.fn();
const openSettings = vi.fn();
const navigateToTaskInput = vi.fn();
let switchOrgPending = false;

vi.mock("@features/auth/hooks/authClient", () => ({
useOptionalAuthenticatedClient: () => null,
}));

vi.mock("@features/auth/hooks/authMutations", () => ({
useLogoutMutation: () => ({ mutate: logoutMutate }),
useSelectProjectMutation: () => ({ mutate: selectProjectMutate }),
useSwitchOrgMutation: () => ({
mutate: switchOrgMutate,
isPending: switchOrgPending,
}),
}));

vi.mock("@stores/navigationStore", () => ({
useNavigationStore: {
getState: () => ({ navigateToTaskInput }),
},
}));

vi.mock("@features/auth/hooks/authQueries", () => ({
useAuthStateValue: (selector: (state: AuthState) => unknown) =>
selector(mockAuthState),
useCurrentUser: () => ({
data: { email: "user@example.com", first_name: "Test", last_name: "User" },
}),
}));

vi.mock("@features/projects/hooks/useProjects", () => ({
useProjects: () => ({
groupedProjects: [],
currentProject: { id: 42, name: "Demo project" },
currentProjectId: 42,
}),
}));

vi.mock("@features/settings/stores/settingsDialogStore", () => ({
useSettingsDialogStore: (
selector: (state: { open: typeof openSettings }) => unknown,
) => selector({ open: openSettings }),
}));

vi.mock("@renderer/trpc/client", () => ({
trpcClient: {
os: { openExternal: { mutate: vi.fn() } },
},
}));

import { ProjectSwitcher } from "./ProjectSwitcher";

function renderInTheme() {
return render(
<Theme>
<ProjectSwitcher />
</Theme>,
);
}

describe("ProjectSwitcher org switcher", () => {
beforeEach(() => {
switchOrgMutate.mockReset();
selectProjectMutate.mockReset();
logoutMutate.mockReset();
openSettings.mockReset();
navigateToTaskInput.mockReset();
switchOrgPending = false;
});

it("hides the Switch organization submenu when there is only one org", async () => {
mockAuthState = {
cloudRegion: "us",
currentOrgId: "org-1",
orgProjectsMap: {
"org-1": { orgName: "Solo Org", projects: [{ id: 42, name: "P1" }] },
},
};

const user = userEvent.setup({ pointerEventsCheck: 0 });
renderInTheme();

await user.click(screen.getByText("Demo project"));

expect(screen.queryByText("Switch organization")).not.toBeInTheDocument();
});

it("shows the submenu with every org and marks the current one", async () => {
mockAuthState = {
cloudRegion: "us",
currentOrgId: "org-1",
orgProjectsMap: {
"org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
"org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
},
};

const user = userEvent.setup({ pointerEventsCheck: 0 });
renderInTheme();

await user.click(screen.getByText("Demo project"));
await user.hover(await screen.findByText("Switch organization"));

const alpha = await screen.findByRole("menuitem", { name: /Alpha/ });
const beta = await screen.findByRole("menuitem", { name: /Beta/ });
expect(alpha).toBeInTheDocument();
expect(beta).toBeInTheDocument();

expect(alpha.querySelector(".text-accent-11")).not.toBeNull();
expect(beta.querySelector(".text-accent-11")).toBeNull();
});

it("fires switchOrg.mutate when picking a different org and navigates on success", async () => {
mockAuthState = {
cloudRegion: "us",
currentOrgId: "org-1",
orgProjectsMap: {
"org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
"org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
},
};

const user = userEvent.setup({ pointerEventsCheck: 0 });
renderInTheme();

await user.click(screen.getByText("Demo project"));
await user.hover(await screen.findByText("Switch organization"));
fireEvent.click(await screen.findByRole("menuitem", { name: /Beta/ }));

expect(switchOrgMutate).toHaveBeenCalledTimes(1);
expect(switchOrgMutate).toHaveBeenCalledWith(
"org-2",
expect.objectContaining({ onSuccess: expect.any(Function) }),
);

const onSuccess = switchOrgMutate.mock.calls[0]?.[1]?.onSuccess;
onSuccess?.();
expect(navigateToTaskInput).toHaveBeenCalledTimes(1);
});

it("skips switchOrg.mutate while a previous switch is pending", async () => {
switchOrgPending = true;
mockAuthState = {
cloudRegion: "us",
currentOrgId: "org-1",
orgProjectsMap: {
"org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
"org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
},
};

const user = userEvent.setup({ pointerEventsCheck: 0 });
renderInTheme();

await user.click(screen.getByText("Demo project"));
await user.hover(await screen.findByText("Switch organization"));
fireEvent.click(await screen.findByRole("menuitem", { name: /Beta/ }));

expect(switchOrgMutate).not.toHaveBeenCalled();
});

it("does not call switchOrg when clicking the active org", async () => {
mockAuthState = {
cloudRegion: "us",
currentOrgId: "org-1",
orgProjectsMap: {
"org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
"org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
},
};

const user = userEvent.setup({ pointerEventsCheck: 0 });
renderInTheme();

await user.click(screen.getByText("Demo project"));
await user.hover(await screen.findByText("Switch organization"));
fireEvent.click(await screen.findByRole("menuitem", { name: /Alpha/ }));

expect(switchOrgMutate).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"
import {
useLogoutMutation,
useSelectProjectMutation,
useSwitchOrgMutation,
} from "@features/auth/hooks/authMutations";
import {
useAuthStateValue,
Expand All @@ -12,6 +13,8 @@ import { useProjects } from "@features/projects/hooks/useProjects";
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import {
ArrowSquareOut,
Building,
Buildings,
Check,
DiscordLogo,
FolderSimple,
Expand Down Expand Up @@ -52,25 +55,38 @@ import {
import { Box } from "@radix-ui/themes";
import { trpcClient } from "@renderer/trpc/client";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
import { useNavigationStore } from "@stores/navigationStore";
import { EXTERNAL_LINKS } from "@utils/links";
import { isMac } from "@utils/platform";
import { ChevronRightIcon } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";

type ProjectInfo = { id: number; name: string };
type ProjectGroup = ReturnType<typeof useProjects>["groupedProjects"][number];
type OrgEntry = Pick<ProjectGroup, "orgId" | "orgName">;

export function ProjectSwitcher() {
const [popoverOpen, setPopoverOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);

const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
const orgProjectsMap = useAuthStateValue((state) => state.orgProjectsMap);
const currentOrgId = useAuthStateValue((state) => state.currentOrgId);
const client = useOptionalAuthenticatedClient();
const { data: currentUser } = useCurrentUser({ client });
const selectProjectMutation = useSelectProjectMutation();
const switchOrgMutation = useSwitchOrgMutation();
const logoutMutation = useLogoutMutation();
const { groupedProjects, currentProject, currentProjectId } = useProjects();

const orgs = useMemo<OrgEntry[]>(
() =>
Object.entries(orgProjectsMap)
.map(([orgId, { orgName }]) => ({ orgId, orgName }))
.sort((a, b) => a.orgName.localeCompare(b.orgName)),
[orgProjectsMap],
);

const handleProjectSelect = (projectId: number) => {
if (projectId !== currentProjectId) {
selectProjectMutation.mutate(projectId);
Expand Down Expand Up @@ -123,6 +139,18 @@ export function ProjectSwitcher() {
logoutMutation.mutate();
};

const handleSwitchOrg = (orgId: string) => {
setPopoverOpen(false);
if (orgId === currentOrgId || switchOrgMutation.isPending) {
return;
}
switchOrgMutation.mutate(orgId, {
onSuccess: () => {
useNavigationStore.getState().navigateToTaskInput();
},
Comment thread
charlesvien marked this conversation as resolved.
});
};

return (
<>
<DropdownMenu open={popoverOpen} onOpenChange={setPopoverOpen}>
Expand Down Expand Up @@ -185,6 +213,29 @@ export function ProjectSwitcher() {
Change project
</DropdownMenuItem>

{orgs.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Buildings size={14} className="text-gray-11" />
Switch organization
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" sideOffset={4}>
{orgs.map((org) => (
<DropdownMenuItem
key={org.orgId}
onClick={() => handleSwitchOrg(org.orgId)}
>
<Building size={14} className="text-gray-11" />
<span className="flex-1">{org.orgName}</span>
{org.orgId === currentOrgId && (
<Check size={14} className="text-accent-11" />
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}

<DropdownMenuItem onClick={handleCreateProject}>
<Plus size={14} className="text-gray-11" />
Create project
Expand Down
Loading