mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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
This commit is contained in:
parent
57894b5b5e
commit
3857931c08
8 changed files with 248 additions and 23 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<typeof import("next/navigation")>()),
|
||||
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 <div data-testid="organization-info-view" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./OrganizationsTable", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { isLoading: boolean }) => (
|
||||
<div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>
|
||||
),
|
||||
default: (props: { isLoading: boolean }) => {
|
||||
mockOrganizationsTableProps = props;
|
||||
return <div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={false} />);
|
||||
|
||||
|
|
@ -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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
|
||||
|
||||
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
|
||||
|
||||
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
|
||||
|
||||
act(() => mockOrganizationInfoView.mock.calls.at(-1)?.[0].onClose());
|
||||
|
||||
expect(window.location.search).not.toContain("org=");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const { id: selectedOrgId, open: openOrg, close: closeOrg } = useDetailParam("org");
|
||||
const [editOrg, setEditOrg] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
|
||||
|
|
@ -108,7 +109,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
|
|||
<OrganizationInfoView
|
||||
organizationId={selectedOrgId}
|
||||
onClose={() => {
|
||||
setSelectedOrgId(null);
|
||||
closeOrg();
|
||||
setEditOrg(false);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
|
|
@ -132,9 +133,9 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
|
|||
isLoading={isLoading}
|
||||
userRole={userRole}
|
||||
searchActive={searchActive}
|
||||
onOrganizationClick={setSelectedOrgId}
|
||||
onOrganizationClick={openOrg}
|
||||
onEditClick={(organizationId) => {
|
||||
setSelectedOrgId(organizationId);
|
||||
openOrg(organizationId);
|
||||
setEditOrg(true);
|
||||
}}
|
||||
onDeleteClick={handleDelete}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import ViewUserDashboard from "./view_users";
|
|||
|
||||
const userListCall = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ViewUserDashboardProps> = ({
|
|||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(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<ViewUserDashboardProps> = ({
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<typeof import("next/navigation")>()),
|
||||
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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
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(<Teams accessToken="test-token" userID="user-123" userRole="Internal User" />);
|
||||
|
||||
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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
|
||||
act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose());
|
||||
|
||||
expect(window.location.search).not.toContain("team=");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const { id: selectedTeamId, open: openTeamDetail, close: closeTeamDetail } = useDetailParam("team");
|
||||
const [editTeam, setEditTeam] = useState<boolean>(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<TeamProps> = ({ 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<TeamProps> = ({ 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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue