diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx new file mode 100644 index 000000000..17ac15824 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index 08d182b22..c9340619e 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -2,6 +2,7 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient" import { useLogoutMutation, useSelectProjectMutation, + useSwitchOrgMutation, } from "@features/auth/hooks/authMutations"; import { useAuthStateValue, @@ -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, @@ -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["groupedProjects"][number]; +type OrgEntry = Pick; 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( + () => + 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); @@ -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(); + }, + }); + }; + return ( <> @@ -185,6 +213,29 @@ export function ProjectSwitcher() { Change project + {orgs.length > 1 && ( + + + + Switch organization + + + {orgs.map((org) => ( + handleSwitchOrg(org.orgId)} + > + + {org.orgName} + {org.orgId === currentOrgId && ( + + )} + + ))} + + + )} + Create project