mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(ui): match organization ID in the organizations search box
The search box on the Organizations page only matched organization_alias, so pasting an ID copied from the table showed the empty state that already promised to try a different name or ID. The box now filters the loaded list in the browser by name or ID (case-insensitive substring) and its placeholder reads "Search by organization name or ID". The server-side org_id and org_alias parameters of /organization/list are unchanged Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
This commit is contained in:
parent
fa533f709b
commit
6a52d9f74c
4 changed files with 115 additions and 26 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FilterInput
|
||||
placeholder="Search by Organization Name"
|
||||
value={filters.org_alias}
|
||||
onChange={(value) => onChange("org_alias", value)}
|
||||
placeholder="Search by organization name or ID"
|
||||
value={filters.search}
|
||||
onChange={(value) => onChange("search", value)}
|
||||
icon={Search}
|
||||
className="w-64"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/components/networking")>();
|
||||
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<typeof OrganizationsTableComponent>;
|
||||
type OrganizationInfoViewProps = React.ComponentProps<typeof OrganizationInfoViewComponent>;
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<OrganizationsPanelProps> = ({ 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<OrganizationsPanelProps> = ({ userRole, acces
|
|||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>({ org_id: "", org_alias: "" });
|
||||
const [filters, setFilters] = useState<FilterState>({ 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<OrganizationsPanelProps> = ({ 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<OrganizationsPanelProps> = ({ userRole, acces
|
|||
onReset={handleFilterReset}
|
||||
/>
|
||||
<OrganizationsTable
|
||||
organizations={organizations}
|
||||
organizations={visibleOrganizations}
|
||||
isLoading={isLoading}
|
||||
userRole={userRole}
|
||||
searchActive={searchActive}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue