[Fix] UI: Team table refresh, infinite team dropdown, leftnav for dashboard routes

- OldTeams: refresh table via fetchTeamsV2 after team create instead of appending
- TeamDropdown: rewrite with useInfiniteTeams for paginated fetch, scroll-to-load, and debounced search
- Update all TeamDropdown consumers to use the new self-fetching API
- Dashboard layout: switch from Sidebar2 to SidebarProvider (leftnav)
- Leftnav: add MIGRATED_PAGES routing for path-based navigation (api-reference)
- Navbar: remove chat button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-21 22:05:26 -07:00
parent 9963b31e07
commit aea8e32048
12 changed files with 238 additions and 114 deletions

View file

@ -1,4 +1,4 @@
import { keepPreviousData, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { Team } from "@/components/key_team_helpers/key_list"; import { Team } from "@/components/key_team_helpers/key_list";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchTeams } from "@/app/(dashboard)/networking"; import { fetchTeams } from "@/app/(dashboard)/networking";
@ -124,6 +124,43 @@ export const useTeam = (teamId?: string) => {
}); });
}; };
const infiniteTeamKeys = createQueryKeys("infiniteTeams");
export const useInfiniteTeams = (
pageSize: number = 50,
search?: string,
organizationId?: string | null,
) => {
const { accessToken, userId, userRole } = useAuthorized();
const isAdmin = userRole === "Admin" || userRole === "Admin Viewer";
return useInfiniteQuery<TeamsResponse>({
queryKey: infiniteTeamKeys.list({
filters: {
pageSize,
...(search && { search }),
...(organizationId && { organizationId }),
...(userId && { userId }),
},
}),
queryFn: async ({ pageParam }) => {
return await teamListCall(accessToken!, pageParam as number, pageSize, {
team_alias: search || undefined,
organizationID: organizationId,
userID: !isAdmin ? userId : undefined,
});
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.total_pages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: Boolean(accessToken),
});
};
const deletedTeamListCall = async ( const deletedTeamListCall = async (
accessToken: string, accessToken: string,
page: number, page: number,

View file

@ -3,7 +3,7 @@
import React, { Suspense, useEffect, useState } from "react"; import React, { Suspense, useEffect, useState } from "react";
import Navbar from "@/components/navbar"; import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext"; import { ThemeProvider } from "@/contexts/ThemeContext";
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner"; import { DebugWarningBanner } from "@/components/DebugWarningBanner";
@ -23,6 +23,17 @@ function withBase(path: string): string {
} }
/** -------------------------------- */ /** -------------------------------- */
/**
* Pages that have been migrated to path-based routing under (dashboard)/.
* When the leftnav triggers one of these, navigate to the path route instead
* of the legacy query-param root page.
*
* Key = legacy page id used in leftnav, Value = route segment under (dashboard)/
*/
const MIGRATED_PAGES: Record<string, string> = {
"api-reference": "api-reference",
};
function LayoutContent({ children }: { children: React.ReactNode }) { function LayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -32,10 +43,17 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
return searchParams.get("page") || "api-keys"; return searchParams.get("page") || "api-keys";
}); });
const updatePage = (newPage: string) => { const handleSetPage = (newPage: string) => {
const newSearchParams = new URLSearchParams(searchParams); // If the page has been migrated to path routing, navigate there
newSearchParams.set("page", newPage); const migratedRoute = MIGRATED_PAGES[newPage];
router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE if (migratedRoute) {
router.push(withBase(migratedRoute));
setPage(newPage);
return;
}
// Otherwise, navigate back to the legacy root page with query params
router.push(withBase(`?page=${newPage}`));
setPage(newPage); setPage(newPage);
}; };
@ -65,7 +83,11 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
<DebugWarningBanner /> <DebugWarningBanner />
<div className="flex flex-1 overflow-auto"> <div className="flex flex-1 overflow-auto">
<div className="mt-2"> <div className="mt-2">
<Sidebar2 defaultSelectedKey={page} accessToken={accessToken} userRole={userRole} /> <SidebarProvider
setPage={handleSetPage}
defaultSelectedKey={page}
sidebarCollapsed={sidebarCollapsed}
/>
</div> </div>
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
</div> </div>

View file

@ -210,9 +210,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</Select2> </Select2>
</Form.Item> </Form.Item>
<Form.Item label="Team" name="team_id"> <Form.Item label="Team" name="team_id">
<Select placeholder="Select Team" style={{ width: "100%" }}> <TeamDropdown />
<TeamDropdown teams={availableTeams} />
</Select>
</Form.Item> </Form.Item>
<Form.Item label="Metadata" name="metadata"> <Form.Item label="Metadata" name="metadata">
@ -294,7 +292,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
name="team_id" name="team_id"
help="If selected, user will be added as a 'user' role to the team." help="If selected, user will be added as a 'user' role to the team."
> >
<TeamDropdown teams={availableTeams} /> <TeamDropdown />
</Form.Item> </Form.Item>
<Form.Item <Form.Item

View file

@ -579,14 +579,12 @@ const Teams: React.FC<TeamProps> = ({
} }
} }
const response: any = await teamCreateCall(accessToken, formValues); await teamCreateCall(accessToken, formValues);
if (teams !== null) {
setTeams([...teams, response]);
} else {
setTeams([response]);
}
console.log(`response for team create call: ${response}`);
NotificationsManager.success("Team created"); NotificationsManager.success("Team created");
await fetchTeamsV2({
page: currentPage,
size: pageSize,
});
form.resetFields(); form.resetFields();
setLoggingSettings([]); setLoggingSettings([]);
setModelAliases({}); setModelAliases({});

View file

@ -387,7 +387,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</span> </span>
{blockScope === "team" ? ( {blockScope === "team" ? (
<TeamDropdown <TeamDropdown
teams={teams}
value={blockTeamId ?? undefined} value={blockTeamId ?? undefined}
onChange={(id) => setBlockTeamId(id || null)} onChange={(id) => setBlockTeamId(id || null)}
/> />

View file

@ -131,7 +131,6 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
tooltip="Select the team for which you want to add this model" tooltip="Select the team for which you want to add this model"
> >
<TeamDropdown <TeamDropdown
teams={teams}
onChange={(value) => { onChange={(value) => {
setTeamAdminSelectedTeam(value); setTeamAdminSelectedTeam(value);
}} }}
@ -325,7 +324,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
}, },
]} ]}
> >
<TeamDropdown teams={teams} disabled={!premiumUser} /> <TeamDropdown disabled={!premiumUser} />
</Form.Item> </Form.Item>
)} )}
{isAdmin && ( {isAdmin && (

View file

@ -723,10 +723,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
name="team_id" name="team_id"
tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team." tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team."
> >
<TeamDropdown <TeamDropdown />
teams={teams}
loading={!teams}
/>
</Form.Item> </Form.Item>
<Divider className="my-4" /> <Divider className="my-4" />

View file

@ -1,46 +1,120 @@
import React from "react"; import React, { useMemo, useState, type UIEvent } from "react";
import { Select } from "antd"; import { Select } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../key_team_helpers/key_list"; import { Team } from "../key_team_helpers/key_list";
interface TeamDropdownProps { interface TeamDropdownProps {
teams?: Team[] | null;
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
/** Callback with the full Team object (or null on clear). */
onTeamSelect?: (team: Team | null) => void;
disabled?: boolean; disabled?: boolean;
loading?: boolean; /** Filter teams by organization. */
organizationId?: string | null;
pageSize?: number;
} }
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled, loading }) => { const SCROLL_THRESHOLD = 0.8;
const DEBOUNCE_MS = 300;
const TeamDropdown: React.FC<TeamDropdownProps> = ({
value,
onChange,
onTeamSelect,
disabled,
organizationId,
pageSize = 50,
}) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_MS,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteTeams(
pageSize,
debouncedSearch || undefined,
organizationId,
);
const teams = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: Team[] = [];
for (const page of data.pages) {
for (const team of page.teams) {
if (seen.has(team.team_id)) continue;
seen.add(team.team_id);
result.push(team);
}
}
return result;
}, [data]);
const options = useMemo(
() =>
teams.map((team) => ({
label: `${team.team_alias} (${team.team_id})`,
value: team.team_id,
})),
[teams],
);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (val: string) => {
setSearchInput(val);
setDebouncedSearch(val);
};
const handleChange = (teamId: string | undefined) => {
onChange?.(teamId ?? "");
if (onTeamSelect) {
const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null;
onTeamSelect(team);
}
};
return ( return (
<Select <Select
showSearch showSearch
placeholder="Search or select a team" placeholder="Search or select a team"
value={value} value={value || undefined}
onChange={onChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
loading={loading}
allowClear allowClear
filterOption={(input, option) => { filterOption={false}
if (!option) return false; onSearch={handleSearch}
// Get team data from the option key searchValue={searchInput}
const team = teams?.find((t) => t.team_id === option.key); onPopupScroll={handlePopupScroll}
if (!team) return false; loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
const searchTerm = input.toLowerCase().trim(); options={options}
const teamAlias = (team.team_alias || "").toLowerCase(); popupRender={(menu) => (
const teamId = (team.team_id || "").toLowerCase(); <>
{menu}
// Search in both team alias and team ID {isFetchingNextPage && (
return teamAlias.includes(searchTerm) || teamId.includes(searchTerm); <div style={{ textAlign: "center", padding: 8 }}>
}} <LoadingOutlined spin />
optionFilterProp="children" </div>
> )}
{teams?.map((team) => ( </>
<Select.Option key={team.team_id} value={team.team_id}> )}
<span className="font-medium">{team.team_alias}</span> <span className="text-gray-500">({team.team_id})</span> />
</Select.Option>
))}
</Select>
); );
}; };

View file

@ -18,7 +18,6 @@ import {
FolderOutlined, FolderOutlined,
KeyOutlined, KeyOutlined,
LineChartOutlined, LineChartOutlined,
MessageOutlined,
PlayCircleOutlined, PlayCircleOutlined,
RobotOutlined, RobotOutlined,
SafetyOutlined, SafetyOutlined,
@ -36,8 +35,34 @@ import { all_admin_roles, internalUserRoles, isAdminRole, isUserTeamAdminForAnyT
import NewBadge from "./common_components/NewBadge"; import NewBadge from "./common_components/NewBadge";
import type { Organization } from "./networking"; import type { Organization } from "./networking";
import UsageIndicator from "./UsageIndicator"; import UsageIndicator from "./UsageIndicator";
import { serverRootPath } from "./networking";
const { Sider } = Layout; const { Sider } = Layout;
/**
* Pages migrated to path-based routing under (dashboard)/.
* Key = legacy page id, Value = route segment.
* Keep in sync with MIGRATED_PAGES in (dashboard)/layout.tsx and
* LEGACY_REDIRECTS in app/page.tsx.
*/
const MIGRATED_PAGES: Record<string, string> = {
"api-reference": "api-reference",
};
/** Build an absolute href for a migrated page, respecting base URL + serverRootPath. */
function migratedHref(routeSegment: string): string {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = raw.replace(/^\/+|\/+$/g, "");
let base = trimmed ? `/${trimmed}/` : "/";
if (serverRootPath && serverRootPath !== "/") {
const cleanRoot = serverRootPath.replace(/\/+$/, "");
const cleanBase = base.replace(/^\/+/, "");
base = `${cleanRoot}/${cleanBase}`;
}
return `${base}${routeSegment}`;
}
// Define the props type // Define the props type
interface SidebarProps { interface SidebarProps {
setPage: (page: string) => void; setPage: (page: string) => void;
@ -379,6 +404,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
// Navigate to page helper // Navigate to page helper
const navigateToPage = (page: string) => { const navigateToPage = (page: string) => {
// For migrated pages, just call setPage — the parent layout handles routing
if (MIGRATED_PAGES[page]) {
setPage(page);
return;
}
const newSearchParams = new URLSearchParams(window.location.search); const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("page", page); newSearchParams.set("page", page);
window.history.pushState(null, "", `?${newSearchParams.toString()}`); window.history.pushState(null, "", `?${newSearchParams.toString()}`);
@ -405,9 +435,11 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
</a> </a>
); );
} }
const params = new URLSearchParams(window.location.search); // For migrated pages, generate a path-based href for right-click "Open in new tab"
params.set("page", page); const migratedRoute = MIGRATED_PAGES[page];
const href = `?${params.toString()}`; const href = migratedRoute
? migratedHref(migratedRoute)
: (() => { const params = new URLSearchParams(window.location.search); params.set("page", page); return `?${params.toString()}`; })();
return ( return (
<a <a
href={href} href={href}

View file

@ -1,12 +1,11 @@
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
import { getProxyBaseUrl } from "@/components/networking"; import { getProxyBaseUrl } from "@/components/networking";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
import { fetchProxySettings } from "@/utils/proxyUtils"; import { fetchProxySettings } from "@/utils/proxyUtils";
import { MenuFoldOutlined, MenuUnfoldOutlined, MessageOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons"; import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons";
import { Button, Switch, Tag } from "antd"; import { Button, Switch, Tag } from "antd";
import Link from "next/link"; import Link from "next/link";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@ -46,11 +45,6 @@ const Navbar: React.FC<NavbarProps> = ({
}) => { }) => {
const baseUrl = getProxyBaseUrl(); const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState(""); const [logoutUrl, setLogoutUrl] = useState("");
const { data: uiConfig } = useUIConfig();
const uiRoot = uiConfig?.server_root_path && uiConfig.server_root_path !== "/"
? uiConfig.server_root_path.replace(/\/+$/, "")
: "";
const chatHref = `${uiRoot}/ui/chat`;
const { logoUrl } = useTheme(); const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadiness(); const { data: healthData } = useHealthReadiness();
const version = healthData?.litellm_version; const version = healthData?.litellm_version;
@ -146,41 +140,6 @@ const Navbar: React.FC<NavbarProps> = ({
</div> </div>
{/* Right side nav items */} {/* Right side nav items */}
<div className="flex items-center space-x-5 ml-auto"> <div className="flex items-center space-x-5 ml-auto">
{/* Chat CTA — always visible, opens in new tab */}
<a
href={chatHref}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 14px",
borderRadius: 8,
background: "#1677ff",
color: "#fff",
fontSize: 13,
fontWeight: 600,
textDecoration: "none",
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLAnchorElement).style.background = "#0958d9"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLAnchorElement).style.background = "#1677ff"; }}
>
<MessageOutlined style={{ fontSize: 14 }} />
Chat
<span style={{
fontSize: 9,
fontWeight: 700,
background: "#fff",
color: "#1677ff",
borderRadius: 3,
padding: "1px 4px",
letterSpacing: "0.05em",
}}>
NEW
</span>
</a>
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} /> <WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
<CommunityEngagementButtons /> <CommunityEngagementButtons />
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below. {/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.

View file

@ -214,19 +214,30 @@ vi.mock("../common_components/PassThroughRoutesSelector", () => ({ default: () =
vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null })); vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null }));
vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null })); vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null }));
vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null })); vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: {
pages: [{ teams: [
{ team_id: "team-1", team_alias: "Team One" },
{ team_id: "team-2", team_alias: "Team Two" },
], total: 2, page: 1, page_size: 50, total_pages: 1 }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("../common_components/team_dropdown", () => ({ vi.mock("../common_components/team_dropdown", () => ({
default: ({ teams, onChange, disabled }: { teams?: any[]; onChange?: (v: string) => void; disabled?: boolean }) => ( default: ({ onChange, disabled }: { onChange?: (v: string) => void; disabled?: boolean }) => (
<select <select
data-testid="team-dropdown" data-testid="team-dropdown"
disabled={disabled} disabled={disabled}
onChange={(e) => onChange?.(e.target.value)} onChange={(e) => onChange?.(e.target.value)}
> >
<option value="">Select team</option> <option value="">Select team</option>
{teams?.map((t: any) => ( <option value="team-1">Team One</option>
<option key={t.team_id} value={t.team_id}> <option value="team-2">Team Two</option>
{t.team_alias}
</option>
))}
</select> </select>
), ),
})); }));

View file

@ -810,19 +810,17 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
help={keyOwner === "service_account" ? "required" : ""} help={keyOwner === "service_account" ? "required" : ""}
> >
<TeamDropdown <TeamDropdown
teams={selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams}
disabled={selectedProjectId !== null} disabled={selectedProjectId !== null}
loading={!teams} organizationId={selectedOrganizationId}
onChange={(teamId) => { onTeamSelect={(team) => {
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; setSelectedCreateKeyTeam(team);
setSelectedCreateKeyTeam(selectedTeam);
setSelectedProjectId(null); setSelectedProjectId(null);
form.setFieldValue("project_id", undefined); form.setFieldValue("project_id", undefined);
// Auto-populate org from team for non-admin users // Auto-populate org from team for non-admin users
if (selectedTeam?.organization_id) { if (team?.organization_id) {
setSelectedOrganizationId(selectedTeam.organization_id); setSelectedOrganizationId(team.organization_id);
form.setFieldValue("organization_id", selectedTeam.organization_id); form.setFieldValue("organization_id", team.organization_id);
} else if (!teamId) { } else if (!team) {
setSelectedOrganizationId(null); setSelectedOrganizationId(null);
form.setFieldValue("organization_id", undefined); form.setFieldValue("organization_id", undefined);
} }