diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx index 7de188bd75d..b4a2d4b459c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx @@ -6,7 +6,7 @@ import OrganizationFilters, { FilterState } from "./OrganizationFilters"; describe("OrganizationFilters", () => { const defaultFilters: FilterState = { org_id: "", - org_alias: "", + search: "", }; it("should render", () => { @@ -24,7 +24,7 @@ describe("OrganizationFilters", () => { />, ); - expect(screen.getByPlaceholderText("Search by Organization Name")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search by organization name or ID")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /reset filters/i })).toBeInTheDocument(); }); @@ -63,12 +63,12 @@ describe("OrganizationFilters", () => { />, ); - const input = screen.getByPlaceholderText("Search by Organization Name"); + const input = screen.getByPlaceholderText("Search by organization name or ID"); fireEvent.change(input, { target: { value: "test" } }); await waitFor( () => { - expect(onChange).toHaveBeenCalledWith("org_alias", expect.any(String)); + expect(onChange).toHaveBeenCalledWith("search", expect.any(String)); }, { timeout: 500 }, ); @@ -103,7 +103,7 @@ describe("OrganizationFilters", () => { const filtersWithActive: FilterState = { ...defaultFilters, - org_alias: "test org", + search: "test org", }; const { container } = render( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx index 6ad2f00fdb0..88c9edc37c0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx @@ -13,7 +13,7 @@ interface OrganizationFiltersProps { type FilterState = { org_id: string; - org_alias: string; + search: string; }; const OrganizationFilters = ({ @@ -23,16 +23,16 @@ const OrganizationFilters = ({ onChange, onReset, }: OrganizationFiltersProps) => { - const hasActiveFilters = !!(filters.org_id || filters.org_alias); + const hasActiveFilters = !!(filters.org_id || filters.search); return (
{/* Search and Filter Controls */}
onChange("org_alias", value)} + placeholder="Search by organization name or ID" + value={filters.search} + onChange={(value) => onChange("search", value)} icon={Search} className="w-64" /> 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 c15b9fcaddb..1ee0087ba09 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,9 +1,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type OrganizationsTableComponent from "./OrganizationsTable"; +import type { Organization } from "@/components/networking"; import type OrganizationInfoViewComponent from "@/components/organization/organization_view"; vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ @@ -14,13 +15,54 @@ vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ __esModule: true, default: () => null, })); +type AuthState = { accessToken: string | null; userId: string | null; userRole: string | null }; +const SIGNED_OUT: AuthState = { accessToken: null, userId: null, userRole: null }; +const SIGNED_IN: AuthState = { accessToken: "sk-test", userId: "user-1", userRole: "Admin" }; +let authState: AuthState = SIGNED_OUT; vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ - accessToken: null, - userId: null, - userRole: null, - }), + default: () => authState, })); + +const makeOrganization = (organization_id: string, organization_alias: string): Organization => ({ + organization_id, + organization_alias, + budget_id: "", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, + teams: null, + users: null, + members: null, +}); +const ALPHA_ORG_ID = "ff9eb074-3f07-4e13-91fb-5337fb10d760"; +const BETA_ORG_ID = "0a1b2c3d-1111-4222-8333-444455556666"; +const SERVER_ORGANIZATIONS: Organization[] = [ + makeOrganization(ALPHA_ORG_ID, "Alpha Org"), + makeOrganization(BETA_ORG_ID, "Beta Org"), + makeOrganization("9f8e7d6c-9999-4888-8777-666655554444", "Gamma Org"), +]; +const matchesServerFilters = (organization: Organization, orgId: string | null, orgAlias: string | null) => { + const idMatches = !orgId || organization.organization_id === orgId; + const aliasMatches = !orgAlias || organization.organization_alias.toLowerCase().includes(orgAlias.toLowerCase()); + return idMatches && aliasMatches; +}; +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + organizationListCall: (_accessToken: string, orgId: string | null = null, orgAlias: string | null = null) => + Promise.resolve( + SERVER_ORGANIZATIONS.filter((organization) => matchesServerFilters(organization, orgId, orgAlias)), + ), + modelAvailableCall: () => Promise.resolve({ data: [] }), + }; +}); type OrganizationsTableProps = React.ComponentProps; type OrganizationInfoViewProps = React.ComponentProps; @@ -80,6 +122,7 @@ const expectQueryString = (queryString: string) => waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString }))); beforeEach(() => { + authState = SIGNED_OUT; capturedTableProps = null; mockOrgInfoView.mockClear(); onUrlUpdate.mockClear(); @@ -168,3 +211,38 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { ); }); }); + +describe("OrganizationsPanel - search by organization name or ID", () => { + const renderSignedInPanel = async () => { + authState = SIGNED_IN; + renderPanel(); + await waitFor(() => expect(capturedTableProps?.organizations).toHaveLength(SERVER_ORGANIZATIONS.length)); + }; + + const search = (value: string) => + fireEvent.change(screen.getByPlaceholderText("Search by organization name or ID"), { target: { value } }); + + const visibleOrganizationIds = () => + capturedTableProps?.organizations.map((organization) => organization.organization_id); + + it.each([ + ["a full organization_id", ALPHA_ORG_ID, ALPHA_ORG_ID], + ["a substring of an organization_id", "5337fb10", ALPHA_ORG_ID], + ["an alias substring, ignoring case", "BETA", BETA_ORG_ID], + ])("keeps only the organization matching %s and drops the others", async (_label, query, expectedId) => { + await renderSignedInPanel(); + + search(query); + + await waitFor(() => expect(visibleOrganizationIds()).toEqual([expectedId])); + }); + + it("shows the search empty state when neither an alias nor an id matches", async () => { + await renderSignedInPanel(); + + search("no-such-org"); + + await waitFor(() => expect(capturedTableProps?.organizations).toEqual([])); + expect(capturedTableProps?.searchActive).toBe(true); + }); +}); 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 4fe4cf47b9c..f1377bf6364 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -3,10 +3,10 @@ import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; import { useQueryClient } from "@tanstack/react-query"; import { parseAsString, useQueryState } from "nuqs"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { toast } from "@/lib/toast"; -import { organizationDeleteCall } from "@/components/networking"; +import { Organization, organizationDeleteCall } from "@/components/networking"; import { OrgCreateDialog } from "@/components/organization/org-create/OrgCreateDialog"; import OrganizationInfoView from "@/components/organization/organization_view"; import { Button } from "@/components/ui/button"; @@ -19,6 +19,15 @@ interface OrganizationsPanelProps { premiumUser: boolean; } +const matchesOrganizationSearch = (organization: Organization, search: string): boolean => { + const needle = search.trim().toLowerCase(); + return ( + needle === "" || + organization.organization_alias.toLowerCase().includes(needle) || + organization.organization_id.toLowerCase().includes(needle) + ); +}; + const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { const [selectedOrgId, setSelectedOrgId] = useQueryState("org", parseAsString.withOptions({ history: "push" })); const [editOrg, setEditOrg] = useState(false); @@ -27,16 +36,18 @@ const OrganizationsPanel: React.FC = ({ userRole, acces const [isDeleting, setIsDeleting] = useState(false); const [isOrgModalVisible, setIsOrgModalVisible] = useState(false); const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ org_id: "", org_alias: "" }); + const [filters, setFilters] = useState({ org_id: "", search: "" }); const queryClient = useQueryClient(); - const { data: organizations = [], isLoading } = useOrganizations({ - org_id: filters.org_id, - org_alias: filters.org_alias, - }); + const { data: organizations = [], isLoading } = useOrganizations({ org_id: filters.org_id }); const { data: userModels = [] } = useUserModels(); - const searchActive = Boolean(filters.org_id || filters.org_alias); + const visibleOrganizations = useMemo( + () => organizations.filter((organization) => matchesOrganizationSearch(organization, filters.search)), + [organizations, filters.search], + ); + + const searchActive = Boolean(filters.org_id || filters.search); const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }); @@ -45,7 +56,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces }; const handleFilterReset = () => { - setFilters({ org_id: "", org_alias: "" }); + setFilters({ org_id: "", search: "" }); }; const handleDelete = (orgId: string | null) => { @@ -129,7 +140,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces onReset={handleFilterReset} />