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:
ryan-crabbe-berri 2026-09-03 11:16:38 -07:00
parent fa533f709b
commit 6a52d9f74c
4 changed files with 115 additions and 26 deletions

View file

@ -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(

View file

@ -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"
/>

View file

@ -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);
});
});

View file

@ -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}