From 3857931c08f73c9b5bcfd8b3d1fbd5876f44d689 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 25 Jul 2026 10:06:31 -0700 Subject: [PATCH] feat(ui): deep-link team, user, and org detail views via query params Extends the ?key=/?model= deep-link pattern to the Teams (?team=), Internal Users (?user=), and Organizations (?org=) pages through a shared useDetailParam hook, so those detail views survive reloads and can be shared as URLs. Teams derives the full team object from /team/info on deep link so is_team_admin stays correct --- .../(dashboard)/hooks/useDetailParam.test.ts | 53 ++++++++++++++++ .../app/(dashboard)/hooks/useDetailParam.ts | 35 +++++++++++ .../_components/OrganizationsPanel.test.tsx | 60 ++++++++++++++++-- .../_components/OrganizationsPanel.tsx | 9 +-- .../users/_components/view_users.test.tsx | 16 ++++- .../users/_components/view_users.tsx | 18 +++--- .../src/components/Teams.test.tsx | 62 +++++++++++++++++++ ui/litellm-dashboard/src/components/Teams.tsx | 18 ++++-- 8 files changed, 248 insertions(+), 23 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.test.ts new file mode 100644 index 00000000000..d648d0fc37f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.test.ts @@ -0,0 +1,53 @@ +/* @vitest-environment jsdom */ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDetailParam } from "./useDetailParam"; + +vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); + +describe("useDetailParam", () => { + beforeEach(() => { + window.history.pushState(null, "", "/teams/"); + }); + + it("open sets the param via history.pushState (no full navigation)", () => { + const spy = vi.spyOn(window.history, "pushState"); + const { result } = renderHook(() => useDetailParam("team")); + act(() => result.current.open("team-1")); + expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("team=team-1")); + spy.mockRestore(); + }); + + it("open preserves unrelated query params like the legacy ?page=", () => { + window.history.pushState(null, "", "/?page=teams"); + const spy = vi.spyOn(window.history, "pushState"); + const { result } = renderHook(() => useDetailParam("team")); + act(() => result.current.open("team-1")); + const url = spy.mock.calls.at(-1)?.[2] as string; + expect(url).toContain("page=teams"); + expect(url).toContain("team=team-1"); + spy.mockRestore(); + }); + + it("close removes only its own param", () => { + window.history.pushState(null, "", "/?page=teams&team=team-1"); + const spy = vi.spyOn(window.history, "pushState"); + const { result } = renderHook(() => useDetailParam("team")); + act(() => result.current.close()); + const url = spy.mock.calls.at(-1)?.[2] as string; + expect(url).toContain("page=teams"); + expect(url).not.toContain("team="); + spy.mockRestore(); + }); + + it("exposes the id from the param", () => { + window.history.pushState(null, "", "/users/?user=user-7"); + const { result } = renderHook(() => useDetailParam("user")); + expect(result.current.id).toBe("user-7"); + }); + + it("id is null when the param is absent", () => { + const { result } = renderHook(() => useDetailParam("org")); + expect(result.current.id).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.ts new file mode 100644 index 00000000000..2cf08332894 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDetailParam.ts @@ -0,0 +1,35 @@ +import { useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +import { navigateWithParams } from "../navigateWithParams"; + +export interface DetailParam { + id: string | null; + open: (id: string) => void; + close: () => void; +} + +export function useDetailParam(param: string): DetailParam { + const searchParams = useSearchParams(); + + const open = useCallback( + (id: string) => { + navigateWithParams((params) => { + params.set(param, id); + }); + }, + [param], + ); + + const close = useCallback(() => { + navigateWithParams((params) => { + params.delete(param); + }); + }, [param]); + + return { + id: searchParams?.get(param) ?? null, + open, + close, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx index d381e5e65ca..9ddf5416fc7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx @@ -1,7 +1,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import React from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockOrganizationInfoView = vi.fn(); +let mockOrganizationsTableProps: any = null; + +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useSearchParams: () => new URLSearchParams(window.location.search), +})); vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ __esModule: true, @@ -18,11 +26,19 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ userRole: null, }), })); +vi.mock("@/components/organization/organization_view", () => ({ + __esModule: true, + default: (props: any) => { + mockOrganizationInfoView(props); + return
; + }, +})); vi.mock("./OrganizationsTable", () => ({ __esModule: true, - default: (props: { isLoading: boolean }) => ( -
isLoading:{String(props.isLoading)}
- ), + default: (props: { isLoading: boolean }) => { + mockOrganizationsTableProps = props; + return
isLoading:{String(props.isLoading)}
; + }, })); import OrganizationsPanel from "./OrganizationsPanel"; @@ -35,6 +51,12 @@ const renderWithQueryClient = (ui: React.ReactElement) => { }; describe("OrganizationsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockOrganizationsTableProps = null; + window.history.pushState(null, "", "/"); + }); + it("gates non-premium users behind the enterprise notice", () => { renderWithQueryClient(); @@ -54,4 +76,32 @@ describe("OrganizationsPanel", () => { // A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton. expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false"); }); + + it("clicking an organization deep-links via ?org=", () => { + renderWithQueryClient(); + + act(() => mockOrganizationsTableProps.onOrganizationClick("org-42")); + + expect(window.location.search).toContain("org=org-42"); + }); + + it("renders OrganizationInfoView from a ?org= URL on load", () => { + window.history.pushState(null, "", "/?org=org-42"); + + renderWithQueryClient(); + + expect(screen.getByTestId("organization-info-view")).toBeInTheDocument(); + expect(mockOrganizationInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-42" })); + expect(screen.queryByTestId("organizations-table")).not.toBeInTheDocument(); + }); + + it("closing the detail view clears ?org=", () => { + window.history.pushState(null, "", "/?org=org-42"); + + renderWithQueryClient(); + + act(() => mockOrganizationInfoView.mock.calls.at(-1)?.[0].onClose()); + + expect(window.location.search).not.toContain("org="); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx index b1c026d3904..ddcf6bc7131 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -1,4 +1,5 @@ import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useDetailParam } from "@/app/(dashboard)/hooks/useDetailParam"; import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; import { useQueryClient } from "@tanstack/react-query"; @@ -19,7 +20,7 @@ interface OrganizationsPanelProps { } const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { - const [selectedOrgId, setSelectedOrgId] = useState(null); + const { id: selectedOrgId, open: openOrg, close: closeOrg } = useDetailParam("org"); const [editOrg, setEditOrg] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [orgToDelete, setOrgToDelete] = useState(null); @@ -108,7 +109,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces { - setSelectedOrgId(null); + closeOrg(); setEditOrg(false); }} accessToken={accessToken} @@ -132,9 +133,9 @@ const OrganizationsPanel: React.FC = ({ userRole, acces isLoading={isLoading} userRole={userRole} searchActive={searchActive} - onOrganizationClick={setSelectedOrgId} + onOrganizationClick={openOrg} onEditClick={(organizationId) => { - setSelectedOrgId(organizationId); + openOrg(organizationId); setEditOrg(true); }} onDeleteClick={handleDelete} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 8dc11babd72..ef03becafbd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -9,6 +9,11 @@ import ViewUserDashboard from "./view_users"; const userListCall = vi.fn(); +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useSearchParams: () => new URLSearchParams(window.location.search), +})); + // Mock the networking module vi.mock("@/components/networking", () => ({ userListCall: (...args: unknown[]) => userListCall(...args), @@ -88,6 +93,7 @@ const renderDashboard = () => describe("ViewUserDashboard", () => { beforeEach(() => { vi.clearAllMocks(); + window.history.pushState(null, "", "/"); userListCall.mockResolvedValue({ users: [makeUser("user-1", "test@example.com")], total: 1, @@ -129,7 +135,7 @@ describe("ViewUserDashboard", () => { expect(screen.getAllByText("user-1").length).toBeGreaterThan(0); }); - it("should swap to the detail view when the identity cell is clicked", async () => { + it("clicking the identity cell deep-links via ?user=", async () => { const user = userEvent.setup(); renderDashboard(); @@ -139,6 +145,14 @@ describe("ViewUserDashboard", () => { await user.click(screen.getByRole("button", { name: /user-1/ })); + expect(window.location.search).toContain("user=user-1"); + }); + + it("renders the detail view from a ?user= URL on load", async () => { + window.history.pushState(null, "", "/?user=user-1"); + + renderDashboard(); + expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false"); expect(screen.queryByText("test@example.com")).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index ce912c09373..3b81dbb407f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -15,6 +15,7 @@ import { } from "@/components/networking"; import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; +import { useDetailParam } from "@/app/(dashboard)/hooks/useDetailParam"; import { updateExistingKeys } from "@/utils/dataUtils"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; @@ -72,7 +73,7 @@ const ViewUserDashboard: React.FC = ({ const [selectionMode, setSelectionMode] = useState(false); const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); - const [selectedUserId, setSelectedUserId] = useState(null); + const { id: selectedUserId, open: openUser, close: closeUser } = useDetailParam("user"); const [openInEditMode, setOpenInEditMode] = useState(false); const [editModalVisible, setEditModalVisible] = useState(false); @@ -139,15 +140,18 @@ const ViewUserDashboard: React.FC = ({ setRowSelection({}); }, []); - const handleUserClick = useCallback((userId: string, openInEdit: boolean = false) => { - setSelectedUserId(userId); - setOpenInEditMode(openInEdit); - }, []); + const handleUserClick = useCallback( + (userId: string, openInEdit: boolean = false) => { + openUser(userId); + setOpenInEditMode(openInEdit); + }, + [openUser], + ); const handleCloseUserInfo = useCallback(() => { - setSelectedUserId(null); + closeUser(); setOpenInEditMode(false); - }, []); + }, [closeUser]); const handleDelete = useCallback((user: UserInfo) => { setUserToDelete(user); diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 7065b1a5fb6..f0c99419bbc 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -8,6 +8,12 @@ import Teams from "./Teams"; const mockTeamInfoView = vi.fn(); const mockUseOrganizations = vi.fn(); +const mockUseTeam = vi.fn(); + +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useSearchParams: () => new URLSearchParams(window.location.search), +})); // The teams grid is unit-tested in TeamsPage/TeamsTable.test.tsx. Here we stub it and drive its callbacks // directly so we can test the Teams shell wiring (delete modal, detail view) without the real DataTable. @@ -31,6 +37,7 @@ vi.mock("./networking", () => ({ // Teams invalidates teamsTableKeys on mutations; the selected team is passed up from the table. vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ teamsTableKeys: { all: ["teamsTable"] }, + useTeam: (teamId?: string) => mockUseTeam(teamId), })); vi.mock("./molecules/notifications_manager", () => ({ @@ -159,6 +166,8 @@ const renderWithQueryClient = (component: React.ReactElement) => { // Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here). beforeEach(() => { mockTeamsTableProps = null; + window.history.pushState(null, "", "/"); + mockUseTeam.mockReturnValue({ data: undefined }); }); describe("Teams - handleCreate organization handling", () => { @@ -659,3 +668,56 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si }); }); }); + +describe("Teams - ?team= deep link", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("clicking a team writes ?team= to the URL", async () => { + renderWithQueryClient(); + + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + act(() => mockTeamsTableProps.onSelectTeam(baseTableTeam)); + + expect(window.location.search).toContain(`team=${baseTableTeam.team_id}`); + }); + + it("renders TeamInfoView from a ?team= URL on load", async () => { + window.history.pushState(null, "", "/?team=team-deeplink"); + + renderWithQueryClient(); + + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); + expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ teamId: "team-deeplink" })); + }); + + it("derives is_team_admin from the fetched /team/info envelope on deep link", async () => { + window.history.pushState(null, "", "/?team=team-deeplink"); + mockUseTeam.mockReturnValue({ + data: { + team_id: "team-deeplink", + team_info: { team_id: "team-deeplink", members_with_roles: [{ user_id: "user-123", role: "admin" }] }, + }, + }); + + renderWithQueryClient(); + + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); + expect(mockUseTeam).toHaveBeenCalledWith("team-deeplink"); + expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ is_team_admin: true })); + }); + + it("closing the detail view clears ?team=", async () => { + window.history.pushState(null, "", "/?team=team-deeplink"); + + renderWithQueryClient(); + + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); + act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose()); + + expect(window.location.search).not.toContain("team="); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 20e9e78e7e4..16dcfbaf920 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -11,7 +11,8 @@ import React, { useEffect, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { PageHeader } from "@/components/shared/PageHeader"; import { Button as UIButton } from "@/components/ui/button"; -import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { teamsTableKeys, useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useDetailParam } from "@/app/(dashboard)/hooks/useDetailParam"; import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector"; @@ -135,9 +136,14 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [editModalVisible, setEditModalVisible] = useState(false); const [selectedTeam, setSelectedTeam] = useState(null); - const [selectedTeamId, setSelectedTeamId] = useState(null); + const { id: selectedTeamId, open: openTeamDetail, close: closeTeamDetail } = useDetailParam("team"); const [editTeam, setEditTeam] = useState(false); + const clickedTeam = selectedTeam?.team_id === selectedTeamId ? selectedTeam : null; + const { data: teamInfoData } = useTeam(clickedTeam ? undefined : selectedTeamId ?? undefined); + const activeTeam = + clickedTeam ?? (teamInfoData as { team_info?: Team } | undefined)?.team_info ?? teamInfoData ?? null; + const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); @@ -482,12 +488,12 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser userID={userID} onSelectTeam={(team) => { setSelectedTeam(team); - setSelectedTeamId(team.team_id); + openTeamDetail(team.team_id); setEditTeam(false); }} onEditTeam={(team) => { setSelectedTeam(team); - setSelectedTeamId(team.team_id); + openTeamDetail(team.team_id); setEditTeam(true); }} onDeleteTeam={handleDelete} @@ -547,11 +553,11 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }} onClose={() => { setSelectedTeam(null); - setSelectedTeamId(null); + closeTeamDetail(); setEditTeam(false); }} accessToken={accessToken} - is_team_admin={is_team_admin(selectedTeam)} + is_team_admin={is_team_admin(activeTeam)} is_proxy_admin={userRole == "Admin"} userModels={userModels} editTeam={editTeam}