Merge pull request #34466 from BerriAI/litellm_/sleepy-pascal-0e7ee6

refactor(ui): migrate access-groups, vector-stores, organizations to shadcn
This commit is contained in:
yuneng-jiang 2026-07-24 07:07:46 -07:00 • committed by GitHub
commit afa8fffd93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 805 additions and 701 deletions

View file

@ -4,11 +4,6 @@
"count": 1
}
},
"src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": {
"no-restricted-imports": {
"count": 2
@ -24,11 +19,6 @@
"count": 1
}
},
"src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 2
@ -2035,11 +2025,6 @@
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": {
"no-nested-ternary": {
"count": 2
@ -2051,18 +2036,10 @@
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/index.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2998,23 +2975,10 @@
}
},
"src/components/common_components/Filters/FilterInput.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/common_components/Filters/FiltersButton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/Filters/ResetFiltersButton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/IconActionButton/BaseActionButton.tsx": {
"no-restricted-imports": {
"count": 1
@ -3576,7 +3540,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 3
"count": 1
}
},
"src/components/page_utils.test.ts": {

View file

@ -1,68 +1,63 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
List,
Row,
Spin,
Tabs,
Tag,
theme,
Typography,
} from "antd";
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
const { Title, Text } = Typography;
const { Content } = Layout;
interface AccessGroupDetailProps {
accessGroupId: string;
onBack: () => void;
}
const MAX_PREVIEW = 5;
function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) {
if (ids.length === 0) {
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{ids.map((id) => (
<Card key={id} size="sm">
<CardContent>
<code className="font-mono text-xs break-all text-foreground">{id}</code>
</CardContent>
</Card>
))}
</div>
);
}
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
const { token } = theme.useToken();
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false);
const [showAllTeams, setShowAllTeams] = useState(false);
const MAX_PREVIEW = 5;
if (isLoading) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin size="large" />
</Flex>
</Content>
<div className="p-6 px-12">
<div className="flex min-h-[300px] items-center justify-center">
<UiLoadingSpinner className="size-8 text-primary" />
</div>
</div>
);
}
if (!accessGroup) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Access group not found" />
</Content>
<div className="p-6 px-12">
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack} className="mb-4">
<ArrowLeftIcon className="size-4" />
</Button>
<p className="py-8 text-center text-sm text-muted-foreground">Access group not found</p>
</div>
);
}
@ -75,224 +70,159 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
const handleEdit = () => {
setIsEditModalVisible(true);
};
const tabItems = [
{
key: "models",
label: (
<Flex align="center" gap={8}>
<LayersIcon size={16} />
Models
<Tag style={{ marginInlineEnd: 0 }}>{modelIds?.length}</Tag>
</Flex>
),
children:
modelIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={modelIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No models assigned to this group" />
),
},
{
key: "mcp",
label: (
<Flex align="center" gap={8}>
<ServerIcon size={16} />
MCP Servers
<Tag>{mcpServerIds?.length}</Tag>
</Flex>
),
children:
mcpServerIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={mcpServerIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No MCP servers assigned to this group" />
),
},
{
key: "agents",
label: (
<Flex align="center" gap={8}>
<BotIcon size={16} />
Agents
<Tag>{agentIds?.length}</Tag>
</Flex>
),
children:
agentIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={agentIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No agents assigned to this group" />
),
},
];
return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
<div className="p-6 px-12">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<Title level={2} style={{ margin: 0 }}>
{accessGroup.access_group_name}
</Title>
<Text type="secondary">
ID: <Text copyable>{accessGroup.access_group_id}</Text>
</Text>
<h1 className="text-xl font-semibold tracking-tight text-foreground">{accessGroup.access_group_name}</h1>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span>ID: {accessGroup.access_group_id}</span>
<CopyButton value={accessGroup.access_group_id} label="Copy access group ID" />
</div>
</div>
</div>
<Button type="primary" icon={<EditIcon size={16} />} onClick={handleEdit}>
<Button onClick={() => setIsEditModalVisible(true)}>
<EditIcon className="size-4" />
Edit Access Group
</Button>
</div>
{/* Group Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Group Details" column={1}>
<Descriptions.Item label="Description">{accessGroup.description || "—"}</Descriptions.Item>
<Descriptions.Item label="Created">
<Card className="mb-6">
<CardHeader>
<CardTitle>Group Details</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Description</dt>
<dd className="text-foreground">{accessGroup.description || "—"}</dd>
<dt className="text-muted-foreground">Created</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(accessGroup.created_at).toLocaleString()}
{accessGroup.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
<>
<span>by</span>
<DefaultProxyAdminTag userId={accessGroup.created_by} />
</Text>
</>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
</dd>
<dt className="text-muted-foreground">Last Updated</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(accessGroup.updated_at).toLocaleString()}
{accessGroup.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
<>
<span>by</span>
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
</Text>
</>
)}
</Descriptions.Item>
</Descriptions>
</Card>
</Row>
{/* Attached Keys & Teams */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Attached Keys
<Tag>{keyIds?.length}</Tag>
</Flex>
}
extra={
keyIds?.length > MAX_PREVIEW ? (
<Button type="link" onClick={() => setShowAllKeys(!showAllKeys)}>
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
</Button>
) : null
}
>
{keyIds?.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedKeys.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty description="No keys attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Attached Teams
<Tag>{teamIds?.length}</Tag>
</Flex>
}
extra={
teamIds?.length > MAX_PREVIEW ? (
<Button type="link" onClick={() => setShowAllTeams(!showAllTeams)}>
{showAllTeams ? "Show Less" : `View All (${teamIds?.length})`}
</Button>
) : null
}
>
{teamIds?.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedTeams.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty description="No teams attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Resources Tabs */}
<Card>
<Tabs defaultActiveKey="models" items={tabItems} />
</dd>
</dl>
</CardContent>
</Card>
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyIcon className="size-4" />
Attached Keys
<Badge variant="secondary">{keyIds.length}</Badge>
</CardTitle>
{keyIds.length > MAX_PREVIEW && (
<CardAction>
<Button variant="link" size="sm" onClick={() => setShowAllKeys(!showAllKeys)}>
{showAllKeys ? "Show Less" : `View All (${keyIds.length})`}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{keyIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedKeys.map((id) => (
<Badge key={id} variant="secondary" className="font-mono">
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No keys attached</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="size-4" />
Attached Teams
<Badge variant="secondary">{teamIds.length}</Badge>
</CardTitle>
{teamIds.length > MAX_PREVIEW && (
<CardAction>
<Button variant="link" size="sm" onClick={() => setShowAllTeams(!showAllTeams)}>
{showAllTeams ? "Show Less" : `View All (${teamIds.length})`}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{teamIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedTeams.map((id) => (
<Badge key={id} variant="secondary" className="font-mono">
{id}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No teams attached</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardContent>
<Tabs defaultValue="models">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="models" className="flex-none gap-2 rounded-none px-4 py-2">
<LayersIcon className="size-4" />
Models
<Badge variant="secondary">{modelIds.length}</Badge>
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none gap-2 rounded-none px-4 py-2">
<ServerIcon className="size-4" />
MCP Servers
<Badge variant="secondary">{mcpServerIds.length}</Badge>
</TabsTrigger>
<TabsTrigger value="agents" className="flex-none gap-2 rounded-none px-4 py-2">
<BotIcon className="size-4" />
Agents
<Badge variant="secondary">{agentIds.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="pt-4">
<ResourceList ids={modelIds} emptyMessage="No models assigned to this group" />
</TabsContent>
<TabsContent value="mcp" className="pt-4">
<ResourceList ids={mcpServerIds} emptyMessage="No MCP servers assigned to this group" />
</TabsContent>
<TabsContent value="agents" className="pt-4">
<ResourceList ids={agentIds} emptyMessage="No agents assigned to this group" />
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Edit Modal */}
<AccessGroupEditModal
visible={isEditModalVisible}
accessGroup={accessGroup}
onCancel={() => setIsEditModalVisible(false)}
/>
</Content>
</div>
);
}

View file

@ -1,10 +1,11 @@
import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd";
import { SearchIcon } from "lucide-react";
import { Plus, SearchIcon, X } from "lucide-react";
import { useMemo, useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { PageHeader } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroupsTable } from "./AccessGroupsTable";
@ -12,9 +13,6 @@ import { AccessGroup } from "./types";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
const { Title, Text } = Typography;
const { Content } = Layout;
function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
return {
id: r.access_group_id,
@ -33,7 +31,6 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
}
export function AccessGroupsPage() {
const { token } = theme.useToken();
const { userRole } = useAuthorized();
// Admin Viewer follows the read-parity rule: see access groups, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
@ -62,31 +59,41 @@ export function AccessGroupsPage() {
}
return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
Access Groups
</Title>
<Text type="secondary">Manage resource permissions for your organization</Text>
</Space>
{canModify && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateModalVisible(true)}>
Create Access Group
</Button>
)}
</Flex>
<Flex align="center" style={{ marginBottom: 12 }}>
<Input
prefix={<SearchIcon size={16} />}
placeholder="Search groups by name, ID, or description..."
style={{ maxWidth: 400 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
<div className="p-6 px-12">
<div className="mb-4">
<PageHeader
title="Access Groups"
subtitle="Manage resource permissions for your organization"
actions={
canModify ? (
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Access Group
</Button>
) : undefined
}
/>
</Flex>
</div>
<div className="mb-3 flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search groups by name, ID, or description..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
<AccessGroupsTable
groups={filteredGroups}
@ -120,6 +127,6 @@ export function AccessGroupsPage() {
}}
confirmLoading={deleteMutation.isPending}
/>
</Content>
</div>
);
}

View file

@ -106,7 +106,7 @@ describe("OrganizationFilters", () => {
org_alias: "test org",
};
render(
const { container } = render(
<OrganizationFilters
filters={filtersWithActive}
showFilters={false}
@ -116,8 +116,7 @@ describe("OrganizationFilters", () => {
/>,
);
const filtersButton = screen.getByRole("button", { name: /^filters$/i });
const badgeWrapper = filtersButton.closest(".ant-badge");
expect(badgeWrapper).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument();
expect(container.querySelector("sup")).toBeInTheDocument();
});
});

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import TestVectorStoreTab from "./TestVectorStoreTab";
import { VectorStore } from "@/components/vector_store_management/types";
@ -60,31 +61,24 @@ describe("TestVectorStoreTab", () => {
expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token");
});
it("should update VectorStoreTester when selecting different vector store", () => {
it("should update VectorStoreTester when selecting different vector store", async () => {
const user = userEvent.setup();
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
// Find the select component
const selectElement = screen.getByRole("combobox");
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Test Store 2"));
// Change selection
fireEvent.mouseDown(selectElement);
// Wait for options to appear and click the second one
const option2 = screen.getByText("Test Store 2");
fireEvent.click(option2);
// Verify the tester component updated
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456");
});
it("should display vector store names in select options", () => {
it("should display vector store names in select options", async () => {
const user = userEvent.setup();
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
const selectElement = screen.getByRole("combobox");
fireEvent.mouseDown(selectElement);
await user.click(screen.getByRole("combobox"));
// Use getAllByText since the selected value also shows the name
expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0);
// The selected store's name may also render in the trigger, so only require at least one match.
expect((await screen.findAllByText("Test Store 1")).length).toBeGreaterThan(0);
expect(screen.getByText("Test Store 2")).toBeInTheDocument();
});
});

View file

@ -1,24 +1,32 @@
import React, { useState } from "react";
import { Card, Select, Typography } from "antd";
import { Card, CardContent } from "@/components/ui/card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { VectorStoreTester } from "./VectorStoreTester";
import { VectorStore } from "@/components/vector_store_management/types";
const { Text, Title } = Typography;
interface TestVectorStoreTabProps {
accessToken: string | null;
vectorStores: VectorStore[];
}
const storeLabel = (store: VectorStore) => store.vector_store_name || store.vector_store_id;
const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | undefined>(
vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined,
);
const [selectedVectorStore, setSelectedVectorStore] = useState<VectorStore | null>(vectorStores[0] ?? null);
if (!accessToken) {
return (
<Card>
<Text type="secondary">Access token is required to test vector stores.</Text>
<CardContent>
<p className="text-sm text-muted-foreground">Access token is required to test vector stores.</p>
</CardContent>
</Card>
);
}
@ -26,9 +34,11 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
if (vectorStores.length === 0) {
return (
<Card>
<div className="text-center py-8">
<Text type="secondary">No vector stores available. Create one first to test it.</Text>
</div>
<CardContent>
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No vector stores available. Create one first to test it.</p>
</div>
</CardContent>
</Card>
);
}
@ -36,36 +46,41 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
return (
<div className="space-y-4">
<Card>
<div className="space-y-4">
<CardContent className="space-y-4">
<div>
<Title level={5}>Select Vector Store</Title>
<Text type="secondary">Choose a vector store to test search queries against</Text>
<h5 className="text-base font-medium text-foreground">Select Vector Store</h5>
<p className="text-sm text-muted-foreground">Choose a vector store to test search queries against</p>
</div>
<Select
value={selectedVectorStoreId}
onChange={setSelectedVectorStoreId}
placeholder="Select a vector store"
size="large"
style={{ width: "100%" }}
showSearch
optionFilterProp="children"
<Combobox
items={vectorStores}
value={selectedVectorStore}
onValueChange={setSelectedVectorStore}
itemToStringLabel={storeLabel}
>
{vectorStores.map((vs) => (
<Select.Option key={vs.vector_store_id} value={vs.vector_store_id}>
<div className="flex flex-col">
<span className="font-medium">{vs.vector_store_name || vs.vector_store_id}</span>
{vs.vector_store_name && (
<span className="text-xs text-gray-500 font-mono">{vs.vector_store_id}</span>
)}
</div>
</Select.Option>
))}
</Select>
</div>
<ComboboxInput className="w-full" placeholder="Select a vector store" />
<ComboboxContent>
<ComboboxEmpty>No matching vector stores</ComboboxEmpty>
<ComboboxList>
{(store: VectorStore) => (
<ComboboxItem key={store.vector_store_id} value={store}>
<div className="flex flex-col">
<span className="font-medium">{storeLabel(store)}</span>
{store.vector_store_name && (
<span className="font-mono text-xs text-muted-foreground">{store.vector_store_id}</span>
)}
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</CardContent>
</Card>
{selectedVectorStoreId && <VectorStoreTester vectorStoreId={selectedVectorStoreId} accessToken={accessToken} />}
{selectedVectorStore && (
<VectorStoreTester vectorStoreId={selectedVectorStore.vector_store_id} accessToken={accessToken} />
)}
</div>
);
};

View file

@ -0,0 +1,156 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { vectorStoreSearchCall } from "@/components/networking";
import { VectorStoreTester } from "./VectorStoreTester";
vi.mock("@/components/networking", () => ({
vectorStoreSearchCall: vi.fn(),
}));
const mockWarning = vi.fn();
vi.mock("@/components/molecules/message_manager", () => ({
__esModule: true,
default: { warning: (...args: unknown[]) => mockWarning(...args) },
}));
const mockFromBackend = vi.fn();
const mockSuccess = vi.fn();
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: {
fromBackend: (...args: unknown[]) => mockFromBackend(...args),
success: (...args: unknown[]) => mockSuccess(...args),
},
}));
const mockSearch = vi.mocked(vectorStoreSearchCall);
const searchResponse = {
object: "vector_store.search_results.page",
search_query: "hello",
data: [
{
score: 0.91234,
content: [{ text: "the quick brown fox", type: "text" }],
file_id: "file-1",
filename: "notes.txt",
attributes: { source: "manual" },
},
],
};
const EMPTY_STATE = "Test your vector store by entering a search query below";
const renderTester = () => render(<VectorStoreTester vectorStoreId="vs_123" accessToken="sk-test" />);
const queryInput = () => screen.getByPlaceholderText(/enter your search query/i);
const searchButton = () => screen.getByRole("button", { name: /search/i });
describe("VectorStoreTester", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearch.mockResolvedValue(searchResponse);
});
it("shows the empty state before any search has run", () => {
renderTester();
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /clear history/i })).not.toBeInTheDocument();
});
it("does not search until a non-blank query is entered", async () => {
const user = userEvent.setup();
renderTester();
await user.click(searchButton());
expect(mockSearch).not.toHaveBeenCalled();
await user.type(queryInput(), "hello");
await user.click(searchButton());
await waitFor(() => expect(mockSearch).toHaveBeenCalledWith("sk-test", "vs_123", "hello"));
});
it("renders the returned result and clears the query input", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("Result 1")).toBeInTheDocument();
expect(screen.getByText("1 results")).toBeInTheDocument();
expect(screen.getByText("Score: 0.9123")).toBeInTheDocument();
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
await waitFor(() => expect(queryInput()).toHaveValue(""));
});
it("expands a result to reveal its content and metadata", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("Result 1")).toBeInTheDocument();
expect(screen.queryByText("the quick brown fox")).not.toBeInTheDocument();
await user.click(screen.getByText("Result 1"));
expect(screen.getByText("the quick brown fox")).toBeInTheDocument();
expect(screen.getByText("File ID:").parentElement).toHaveTextContent("file-1");
expect(screen.getByText("Filename:").parentElement).toHaveTextContent("notes.txt");
});
it("warns instead of searching when the query is only whitespace", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), " ");
await user.type(queryInput(), "{Enter}");
expect(mockWarning).toHaveBeenCalledWith("Please enter a search query");
expect(mockSearch).not.toHaveBeenCalled();
});
it("submits on Enter but not on Shift+Enter", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.type(queryInput(), "{Shift>}{Enter}{/Shift}");
expect(mockSearch).not.toHaveBeenCalled();
await user.type(queryInput(), "{Enter}");
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
});
it("reports a failed search and keeps the history empty", async () => {
const user = userEvent.setup();
mockSearch.mockRejectedValue(new Error("boom"));
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith("Failed to search vector store"));
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
});
it("clears the search history", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("Result 1")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /clear history/i }));
expect(screen.queryByText("Result 1")).not.toBeInTheDocument();
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
});
});

View file

@ -1,12 +1,13 @@
import React, { useState } from "react";
import { Button, Input, Card, Typography, Spin, Divider } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { SendOutlined, DatabaseOutlined, LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import { ChevronDown, ChevronRight, Database, Send } from "lucide-react";
import { vectorStoreSearchCall } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
const { TextArea } = Input;
const { Text, Title } = Typography;
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface VectorStoreContent {
text: string;
@ -98,18 +99,16 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
};
return (
<Card className="w-full rounded-xl shadow-md">
<div className="flex flex-col h-[600px]">
<Card className={`w-full py-0 shadow-md ${className}`}>
<div className="flex h-150 flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center">
<DatabaseOutlined className="mr-2 text-blue-500" />
<Title level={4} className="mb-0">
Test Vector Store
</Title>
<Database className="mr-2 size-4 text-primary" />
<h4 className="text-base font-medium text-foreground">Test Vector Store</h4>
</div>
{searchHistory.length > 0 && (
<Button onClick={clearHistory} size="small">
<Button variant="outline" size="sm" onClick={clearHistory}>
Clear History
</Button>
)}
@ -118,9 +117,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{/* Results Area */}
<div className="flex-1 overflow-auto p-4 pb-0">
{searchHistory.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<DatabaseOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
<Text>Test your vector store by entering a search query below</Text>
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Database className="mb-4 size-12" />
<p className="text-sm">Test your vector store by entering a search query below</p>
</div>
) : (
<div className="space-y-4">
@ -128,10 +127,10 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
<div key={index} className="space-y-2">
{/* User Query */}
<div className="text-right">
<div className="inline-block max-w-[80%] rounded-lg shadow-xs p-3 bg-blue-50 border border-blue-200">
<div className="flex items-center gap-2 mb-1">
<div className="inline-block max-w-[80%] rounded-lg bg-muted p-3 shadow-xs ring-1 ring-foreground/10">
<div className="mb-1 flex items-center gap-2">
<strong className="text-sm">Query</strong>
<span className="text-xs text-gray-500">{formatTimestamp(entry.timestamp)}</span>
<span className="text-xs text-muted-foreground">{formatTimestamp(entry.timestamp)}</span>
</div>
<div className="text-left">{entry.query}</div>
</div>
@ -139,12 +138,12 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{/* Vector Store Response */}
<div className="text-left">
<div className="inline-block max-w-[80%] rounded-lg shadow-xs p-3 bg-white border border-gray-200">
<div className="flex items-center gap-2 mb-2">
<DatabaseOutlined className="text-green-500" />
<div className="inline-block max-w-[80%] rounded-lg bg-card p-3 shadow-xs ring-1 ring-foreground/10">
<div className="mb-2 flex items-center gap-2">
<Database className="size-4 text-primary" />
<strong className="text-sm">Vector Store Results</strong>
{entry.response && (
<span className="text-xs px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600">
<span className="rounded-sm bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{entry.response.data?.length || 0} results
</span>
)}
@ -156,40 +155,42 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
return (
<div key={resultIndex} className="border rounded-lg overflow-hidden bg-gray-50">
<div key={resultIndex} className="overflow-hidden rounded-lg border bg-muted/50">
{/* Clickable Header */}
<div
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-100 transition-colors"
className="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-muted"
onClick={() => toggleResultExpansion(index, resultIndex)}
>
<div className="flex items-center">
{isExpanded ? (
<DownOutlined className="text-gray-500 mr-2" />
<ChevronDown className="mr-2 size-4 text-muted-foreground" />
) : (
<RightOutlined className="text-gray-500 mr-2" />
<ChevronRight className="mr-2 size-4 text-muted-foreground" />
)}
<span className="font-medium text-sm">Result {resultIndex + 1}</span>
<span className="text-sm font-medium">Result {resultIndex + 1}</span>
{/* Show preview of content when collapsed */}
{!isExpanded && result.content && result.content[0] && (
<span className="ml-2 text-xs text-gray-500 truncate max-w-md">
<span className="ml-2 max-w-md truncate text-xs text-muted-foreground">
- {result.content[0].text.substring(0, 100)}...
</span>
)}
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
<span className="rounded-sm bg-muted px-2 py-1 text-xs text-foreground">
Score: {result.score.toFixed(4)}
</span>
</div>
{/* Expandable Content */}
{isExpanded && (
<div className="border-t bg-white p-3">
<div className="border-t bg-card p-3">
{/* Content */}
{result.content &&
result.content.map((content, contentIndex) => (
<div key={contentIndex} className="mb-3">
<div className="text-xs text-gray-500 mb-1">Content ({content.type})</div>
<div className="text-sm bg-gray-50 p-3 rounded-sm border text-gray-800 max-h-40 overflow-y-auto">
<div className="mb-1 text-xs text-muted-foreground">
Content ({content.type})
</div>
<div className="max-h-40 overflow-y-auto rounded-sm border bg-muted/50 p-3 text-sm text-foreground">
{content.text}
</div>
</div>
@ -197,23 +198,23 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{/* Metadata */}
{(result.file_id || result.filename || result.attributes) && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-xs text-gray-500 mb-2 font-medium">Metadata</div>
<div className="mt-3 border-t pt-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">Metadata</div>
<div className="space-y-2 text-xs">
{result.file_id && (
<div className="bg-gray-50 p-2 rounded-sm">
<div className="rounded-sm bg-muted/50 p-2">
<span className="font-medium">File ID:</span> {result.file_id}
</div>
)}
{result.filename && (
<div className="bg-gray-50 p-2 rounded-sm">
<div className="rounded-sm bg-muted/50 p-2">
<span className="font-medium">Filename:</span> {result.filename}
</div>
)}
{result.attributes && Object.keys(result.attributes).length > 0 && (
<div className="bg-gray-50 p-2 rounded-sm">
<span className="font-medium block mb-1">Attributes:</span>
<pre className="text-xs bg-white p-2 rounded-sm border overflow-x-auto">
<div className="rounded-sm bg-muted/50 p-2">
<span className="mb-1 block font-medium">Attributes:</span>
<pre className="overflow-x-auto rounded-sm border bg-card p-2 text-xs">
{JSON.stringify(result.attributes, null, 2)}
</pre>
</div>
@ -228,45 +229,40 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
})}
</div>
) : (
<div className="text-gray-500 text-sm">No results found</div>
<div className="text-sm text-muted-foreground">No results found</div>
)}
</div>
</div>
{index < searchHistory.length - 1 && <Divider />}
{index < searchHistory.length - 1 && <Separator />}
</div>
))}
</div>
)}
{isLoading && (
<div className="flex justify-center items-center my-4">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<div className="my-4 flex items-center justify-center">
<UiLoadingSpinner className="size-6 text-primary" />
</div>
)}
</div>
{/* Input Area */}
<div className="p-4 border-t border-gray-200 bg-white">
<div className="border-t bg-card p-4">
<div className="flex items-end space-x-2">
<div className="flex-1">
<TextArea
<Textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter your search query... (Shift+Enter for new line)"
disabled={isLoading}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ resize: "none" }}
rows={1}
className="field-sizing-fixed max-h-24 min-h-9 resize-none"
/>
</div>
<Button
type="primary"
onClick={handleSearch}
disabled={isLoading || !query.trim()}
icon={<SendOutlined />}
loading={isLoading}
>
<Button onClick={handleSearch} disabled={isLoading || !query.trim()}>
{isLoading ? <UiLoadingSpinner className="size-4" /> : <Send className="size-4" />}
Search
</Button>
</div>

View file

@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { vectorStoreListCall } from "@/components/networking";
@ -25,18 +26,25 @@ vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null }
const mockVectorStoreListCall = vi.mocked(vectorStoreListCall);
const openManageTab = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" }));
};
describe("VectorStoreManagement loading state", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => {
const user = userEvent.setup();
render(<VectorStoreManagement accessToken={null} userID={null} userRole={null} />);
await openManageTab(user);
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
});
it("should show the loading state until the vector store fetch settles", async () => {
const user = userEvent.setup();
let resolveFetch: (value: { data: never[] }) => void = () => {};
mockVectorStoreListCall.mockReturnValue(
new Promise((resolve) => {
@ -44,6 +52,7 @@ describe("VectorStoreManagement loading state", () => {
}),
);
render(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
await openManageTab(user);
expect(screen.getByText("table-loading")).toBeInTheDocument();
resolveFetch({ data: [] });

View file

@ -1,17 +1,5 @@
import React, { useState, useEffect } from "react";
import {
Icon,
Button as TremorButton,
Col,
Text,
Grid,
TabGroup,
TabList,
Tab,
TabPanels,
TabPanel,
} from "@tremor/react";
import { RefreshIcon } from "@heroicons/react/outline";
import { RefreshCw } from "lucide-react";
import {
vectorStoreListCall,
vectorStoreDeleteCall,
@ -27,6 +15,9 @@ import CreateVectorStore from "./CreateVectorStore";
import TestVectorStoreTab from "./TestVectorStoreTab";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
interface VectorStoreProps {
accessToken: string | null;
@ -45,6 +36,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | null>(null);
const [editVectorStore, setEditVectorStore] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { onTabChange, hasVisited } = useVisitedTabs("create");
const fetchVectorStores = async () => {
if (!accessToken) {
@ -147,61 +139,56 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
<div className="mx-4 h-[75vh]">
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
<div className="flex justify-between mt-2 w-full items-center mb-4">
<h1>Vector Store Management</h1>
<h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
<Icon
icon={RefreshIcon}
variant="shadow"
size="xs"
className="self-center cursor-pointer"
onClick={handleRefreshClick}
/>
{lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
<Button variant="outline" size="icon-sm" aria-label="Refresh" onClick={handleRefreshClick}>
<RefreshCw className="size-4" />
</Button>
</div>
</div>
<Text className="mb-4">
<p>You can use vector stores to store and retrieve LLM embeddings.</p>
</Text>
<p className="mb-4 text-sm text-muted-foreground">
You can use vector stores to store and retrieve LLM embeddings.
</p>
<TabGroup>
<TabList className="mb-6">
<Tab>Create Vector Store</Tab>
<Tab>Manage Vector Stores</Tab>
<Tab>Test Vector Store</Tab>
</TabList>
<Tabs defaultValue="create" onValueChange={onTabChange}>
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="create" className="flex-none rounded-none px-4 py-2">
Create Vector Store
</TabsTrigger>
<TabsTrigger value="manage" className="flex-none rounded-none px-4 py-2">
Manage Vector Stores
</TabsTrigger>
<TabsTrigger value="test" className="flex-none rounded-none px-4 py-2">
Test Vector Store
</TabsTrigger>
</TabsList>
<TabPanels>
{/* Tab 1: Create Vector Store */}
<TabPanel>
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
</TabPanel>
<TabsContent keepMounted={hasVisited("create")} value="create">
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
</TabsContent>
{/* Tab 2: Manage Vector Stores */}
<TabPanel>
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store
</TremorButton>
<TabsContent keepMounted={hasVisited("manage")} value="manage">
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store
</Button>
<Grid numItems={1} className="gap-2 pt-2 pb-2 w-full mt-2">
<Col numColSpan={1}>
<VectorStoreTable
data={vectorStores}
isLoading={isLoadingVectorStores}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Col>
</Grid>
</TabPanel>
<div className="grid grid-cols-1 gap-2 pt-2 pb-2 w-full mt-2">
<VectorStoreTable
data={vectorStores}
isLoading={isLoadingVectorStores}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
</TabsContent>
{/* Tab 3: Test Vector Store */}
<TabPanel>
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
</TabPanel>
</TabPanels>
</TabGroup>
<TabsContent keepMounted={hasVisited("test")} value="test">
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
</TabsContent>
</Tabs>
{/* Create Vector Store Modal */}
<VectorStoreForm

View file

@ -1,7 +1,7 @@
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { cx } from "@/lib/cva.config";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Input } from "antd";
import { LucideIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
@ -30,12 +30,13 @@ export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, on
};
return (
<Input
placeholder={placeholder}
value={localValue}
onChange={handleChange}
prefix={Icon ? <Icon size={16} className="text-gray-500" /> : undefined}
className={cx("w-64", className)}
/>
<InputGroup className={cx("w-64", className)}>
{Icon && (
<InputGroupAddon>
<Icon className="size-4 text-muted-foreground" />
</InputGroupAddon>
)}
<InputGroupInput placeholder={placeholder} value={localValue} onChange={handleChange} />
</InputGroup>
);
};

View file

@ -21,12 +21,18 @@ describe("FiltersButton", () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should show badge when hasActiveFilters is true", () => {
it("should show the active-filter indicator when hasActiveFilters is true", () => {
const onClick = vi.fn();
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={true} />);
const button = screen.getByRole("button", { name: /filters/i });
const badgeWrapper = button.closest(".ant-badge");
expect(badgeWrapper).toBeInTheDocument();
expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
expect(container.querySelector("sup")).toBeInTheDocument();
});
it("should not show the active-filter indicator when hasActiveFilters is false", () => {
const onClick = vi.fn();
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={false} />);
expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
expect(container.querySelector("sup")).not.toBeInTheDocument();
});
it("should render custom label when provided", () => {

View file

@ -1,4 +1,5 @@
import { Badge, Button } from "antd";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { Filter } from "lucide-react";
import React from "react";
@ -16,10 +17,14 @@ export const FiltersButton: React.FC<FiltersButtonProps> = ({
label = "Filters",
}) => {
return (
<Badge color="blue" dot={hasActiveFilters}>
<Button type="default" onClick={onClick} icon={<Filter size={16} />} className={active ? "bg-gray-100" : ""}>
<span className="relative inline-flex">
<Button variant="outline" onClick={onClick} className={cn(active && "bg-muted")}>
<Filter className="size-4" />
{label}
</Button>
</Badge>
{hasActiveFilters && (
<sup aria-hidden="true" className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-primary" />
)}
</span>
);
};

View file

@ -1,4 +1,4 @@
import { Button } from "antd";
import { Button } from "@/components/ui/button";
import { RotateCcw } from "lucide-react";
import React from "react";
@ -9,7 +9,8 @@ interface ResetFiltersButtonProps {
export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label = "Reset Filters" }) => {
return (
<Button type="default" onClick={onClick} icon={<RotateCcw size={16} />}>
<Button variant="outline" onClick={onClick}>
<RotateCcw className="size-4" />
{label}
</Button>
);

View file

@ -73,6 +73,7 @@ const mockUseTeams = vi.fn(() => mockUseTeamsData);
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
useTeam: () => ({ data: undefined }),
}));
const mockOrg = {
@ -204,3 +205,33 @@ test("should display team ID as fallback when alias is not found", async () => {
expect(screen.getByText("team_999")).toBeInTheDocument();
});
});
test("should keep unsaved settings edits when switching tabs and back", async () => {
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
const user = userEvent.setup();
renderWithProviders(
<OrganizationInfoView
organizationId="org_123"
onClose={() => {}}
accessToken="test-token"
is_org_admin={false}
is_proxy_admin={true}
userModels={[]}
editOrg={false}
/>,
);
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /Edit Settings/i }));
const alias = await screen.findByLabelText(/Organization Name/i);
await user.clear(alias);
await user.type(alias, "Renamed Org");
expect(alias).toHaveValue("Renamed Org");
await user.click(screen.getByRole("tab", { name: "Overview" }));
await user.click(screen.getByRole("tab", { name: "Settings" }));
expect(screen.getByLabelText(/Organization Name/i)).toHaveValue("Renamed Org");
});

View file

@ -1,14 +1,17 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useQueryClient } from "@tanstack/react-query";
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { createTeamAliasMap } from "@/utils/teamUtils";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Card, Grid, Text, Title, Button as TremorButton } from "@tremor/react";
import { Button, Tabs, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import { CheckIcon, CopyIcon } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import React, { useMemo, useState } from "react";
import MemberTable from "../common_components/MemberTable";
import UserSearchModal from "../common_components/user_search_modal";
@ -48,9 +51,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const canEditOrg = is_org_admin || is_proxy_admin;
const { data: teams } = useTeams();
const { onTabChange, hasVisited } = useVisitedTabs(editOrg ? "settings" : "overview");
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
@ -118,16 +121,6 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
return <div className="p-4">Organization not found</div>;
}
const copyToClipboard = async (text: string | null | undefined, key: string) => {
const success = await utilCopyToClipboard(text);
if (success) {
setCopiedStates((prev) => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [key]: false }));
}, 2000);
}
};
const orgExtraColumns: ColumnsType<Member> = [
{
title: "Spend (USD)",
@ -144,216 +137,213 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
render: (_: unknown, record: Member) => {
const orgMember =
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
return (
<Typography.Text>
{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}
</Typography.Text>
);
return <span>{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}</span>;
},
},
];
return (
<div className="w-full h-screen p-4 bg-white">
<div className="flex justify-between items-center mb-6">
<div className="h-screen w-full bg-background p-4">
<div className="mb-6 flex items-center justify-between">
<div>
<TremorButton icon={ArrowLeftIcon} onClick={onClose} variant="light" className="mb-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
Back to Organizations
</TremorButton>
<Title>{orgData.organization_alias}</Title>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{orgData.organization_id}</Text>
<Button
type="text"
size="small"
icon={copiedStates["org-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(orgData.organization_id, "org-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["org-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
</Button>
<h1 className="text-xl font-semibold tracking-tight text-foreground">{orgData.organization_alias}</h1>
<div className="flex items-center gap-1">
<span className="font-mono text-sm text-muted-foreground">{orgData.organization_id}</span>
<CopyButton value={orgData.organization_id} label="Copy organization ID" iconClassName="size-3" />
</div>
</div>
</div>
<Tabs
defaultActiveKey={editOrg ? "settings" : "overview"}
className="mb-4"
items={[
{
key: "overview",
label: "Overview",
children: (
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Organization Details</Text>
<div className="mt-2">
<Text>Created: {new Date(orgData.created_at).toLocaleDateString()}</Text>
<Text>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</Text>
<Text>Created By: {orgData.created_by}</Text>
</div>
</Card>
<Tabs defaultValue={editOrg ? "settings" : "overview"} onValueChange={onTabChange} className="mb-4">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
<TabsTrigger value="members" className="flex-none rounded-none px-4 py-2">
Members
</TabsTrigger>
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
</TabsList>
<Card>
<Text>Budget Status</Text>
<div className="mt-2">
<Title>${formatNumberWithCommas(orgData.spend, 4)}</Title>
<Text>
of{" "}
{orgData.litellm_budget_table.max_budget === null
? "Unlimited"
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
</Text>
{orgData.litellm_budget_table.budget_duration && (
<Text className="text-gray-500">Reset: {orgData.litellm_budget_table.budget_duration}</Text>
)}
</div>
</Card>
<TabsContent keepMounted={hasVisited("overview")} value="overview" className="pt-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Organization Details</p>
<div className="mt-2 text-sm text-foreground">
<p>Created: {new Date(orgData.created_at).toLocaleDateString()}</p>
<p>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</p>
<p>Created By: {orgData.created_by}</p>
</div>
</CardContent>
</Card>
<Card>
<Text>Rate Limits</Text>
<div className="mt-2">
<Text>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</Text>
<Text>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</Text>
{orgData.litellm_budget_table.max_parallel_requests && (
<Text>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</Text>
)}
</div>
</Card>
<Card>
<Text>Models</Text>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.models.length === 0 ? (
<Badge color="red">All proxy models</Badge>
) : (
orgData.models.map((model, index) => (
<Badge key={index} color="red">
{model}
</Badge>
))
)}
</div>
</Card>
<Card>
<Text>Teams</Text>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.teams?.map((team, index) => (
<Badge key={index} color="red">
{teamAliasMap[team.team_id] || team.team_id}
</Badge>
))}
</div>
</Card>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
variant="card"
accessToken={accessToken}
/>
</Grid>
),
},
{
key: "members",
label: "Members",
children: (
<div className="space-y-4">
<MemberTable
members={(orgData.members || []).map((m) => ({
role: m.user_role || "",
user_id: m.user_id,
user_email: m.user_email,
}))}
canEdit={canEditOrg}
onEdit={(member) => {
setSelectedEditMember(member);
setIsEditMemberModalVisible(true);
}}
onDelete={(member) => handleMemberDelete(member)}
onAddMember={() => setIsAddMemberModalVisible(true)}
roleColumnTitle="Organization Role"
extraColumns={orgExtraColumns}
emptyText="No members found"
/>
</div>
),
},
{
key: "settings",
label: "Settings",
children: (
<Card className="overflow-y-auto max-h-[65vh]">
<div className="flex justify-between items-center mb-4">
<Title>Organization Settings</Title>
{canEditOrg && !isEditing && (
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Budget Status</p>
<div className="mt-2 text-sm text-foreground">
<p className="text-xl font-semibold">${formatNumberWithCommas(orgData.spend, 4)}</p>
<p>
of{" "}
{orgData.litellm_budget_table.max_budget === null
? "Unlimited"
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
</p>
{orgData.litellm_budget_table.budget_duration && (
<p className="text-muted-foreground">Reset: {orgData.litellm_budget_table.budget_duration}</p>
)}
</div>
</CardContent>
</Card>
{isEditing ? (
<OrgSettingsForm
organizationId={organizationId}
org={orgData}
accessToken={accessToken || ""}
onCancel={() => setIsEditing(false)}
onSaved={() => setIsEditing(false)}
/>
) : (
<div className="space-y-4">
<div>
<Text className="font-medium">Organization Name</Text>
<div>{orgData.organization_alias}</div>
</div>
<div>
<Text className="font-medium">Organization ID</Text>
<div className="font-mono">{orgData.organization_id}</div>
</div>
<div>
<Text className="font-medium">Created At</Text>
<div>{new Date(orgData.created_at).toLocaleString()}</div>
</div>
<div>
<Text className="font-medium">Models</Text>
<div className="flex flex-wrap gap-2 mt-1">
{orgData.models.map((model, index) => (
<Badge key={index} color="red">
{model}
</Badge>
))}
</div>
</div>
<div>
<Text className="font-medium">Rate Limits</Text>
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
</div>
<div>
<Text className="font-medium">Budget</Text>
<div>
Max:{" "}
{orgData.litellm_budget_table.max_budget !== null
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
: "No Limit"}
</div>
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
</div>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Rate Limits</p>
<div className="mt-2 text-sm text-foreground">
<p>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</p>
<p>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</p>
{orgData.litellm_budget_table.max_parallel_requests && (
<p>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</p>
)}
</div>
</CardContent>
</Card>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
variant="inline"
className="pt-4 border-t border-gray-200"
accessToken={accessToken}
/>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Models</p>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.models.length === 0 ? (
<Badge variant="secondary">All proxy models</Badge>
) : (
orgData.models.map((model, index) => (
<Badge key={index} variant="secondary">
{model}
</Badge>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Teams</p>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.teams?.map((team, index) => (
<Badge key={index} variant="secondary">
{teamAliasMap[team.team_id] || team.team_id}
</Badge>
))}
</div>
</CardContent>
</Card>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
variant="card"
accessToken={accessToken}
/>
</div>
</TabsContent>
<TabsContent keepMounted={hasVisited("members")} value="members" className="pt-4">
<div className="space-y-4">
<MemberTable
members={(orgData.members || []).map((m) => ({
role: m.user_role || "",
user_id: m.user_id,
user_email: m.user_email,
}))}
canEdit={canEditOrg}
onEdit={(member) => {
setSelectedEditMember(member);
setIsEditMemberModalVisible(true);
}}
onDelete={(member) => handleMemberDelete(member)}
onAddMember={() => setIsAddMemberModalVisible(true)}
roleColumnTitle="Organization Role"
extraColumns={orgExtraColumns}
emptyText="No members found"
/>
</div>
</TabsContent>
<TabsContent keepMounted={hasVisited("settings")} value="settings" className="pt-4">
<Card className="max-h-[65vh] overflow-y-auto">
<CardContent>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Organization Settings</h2>
{canEditOrg && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Settings</Button>}
</div>
{isEditing ? (
<OrgSettingsForm
organizationId={organizationId}
org={orgData}
accessToken={accessToken || ""}
onCancel={() => setIsEditing(false)}
onSaved={() => setIsEditing(false)}
/>
) : (
<div className="space-y-4 text-sm">
<div>
<p className="font-medium text-foreground">Organization Name</p>
<div>{orgData.organization_alias}</div>
</div>
)}
</Card>
),
},
]}
/>
<div>
<p className="font-medium text-foreground">Organization ID</p>
<div className="font-mono">{orgData.organization_id}</div>
</div>
<div>
<p className="font-medium text-foreground">Created At</p>
<div>{new Date(orgData.created_at).toLocaleString()}</div>
</div>
<div>
<p className="font-medium text-foreground">Models</p>
<div className="mt-1 flex flex-wrap gap-2">
{orgData.models.map((model, index) => (
<Badge key={index} variant="secondary">
{model}
</Badge>
))}
</div>
</div>
<div>
<p className="font-medium text-foreground">Rate Limits</p>
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
</div>
<div>
<p className="font-medium text-foreground">Budget</p>
<div>
Max:{" "}
{orgData.litellm_budget_table.max_budget !== null
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
: "No Limit"}
</div>
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
</div>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
variant="inline"
className="border-t pt-4"
accessToken={accessToken}
/>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<UserSearchModal
isVisible={isAddMemberModalVisible}
onCancel={() => setIsAddMemberModalVisible(false)}

View file

@ -0,0 +1,13 @@
import { useCallback, useState } from "react";
export function useVisitedTabs(initialTab: string) {
const [visited, setVisited] = useState<ReadonlySet<string>>(() => new Set([initialTab]));
const onTabChange = useCallback((value: unknown) => {
setVisited((previous) => new Set(previous).add(String(value)));
}, []);
const hasVisited = useCallback((value: string) => visited.has(value), [visited]);
return { onTabChange, hasVisited };
}